Aller au contenu principal
Leçon 21 sur 25 · Construire des Agents OpenAI

Intégration avancée dans votre app

Leçon 21 sur 25 Mis à jour le 3 novembre 2026 Développeur
Sur cette page
  1. Ce qui reste à faire après la démo
  2. Authentifier chaque requête
  3. À chacun ses conversations
  4. Donner au modèle le contexte de l’application
  5. Outils côté client
  6. Piloter ChatKit depuis votre application
  7. Observer ce qui se passe
  8. Avant la mise en production
  9. Points clés à retenir

Leçon réécrite le 3 novembre 2026. La version précédente reposait sur des propriétés et des événements inventés (ChatProvider, mode « panneau latéral », persistance par l’interface). Elle suit désormais les guides du SDK Python openai-chatkit 1.6.5 (Prepare your app for production, Pass extra app context, Update the client during a response) et les types de @openai/chatkit-react 1.6.1 : le code serveur a été exécuté en local, le code React vérifié par compilation. Sources : Prepare your app for production · Pass extra app context to your model · Update the client during a response · Advanced integrations with ChatKit.

Ce qui reste à faire après la démo

Les quatre leçons précédentes donnent un assistant qui fonctionne. Pour qu’il vive dans un vrai produit, il lui manque ce qui fait la différence entre une démo et un service : savoir qui parle, ne montrer à chacun que ses conversations, dialoguer avec le reste de l’application, être observable. Cette leçon reprend ces points un par un.

Authentifier chaque requête

Le SDK ne fait aucune hypothèse sur votre authentification : il vous laisse vérifier l’utilisateur, puis transmettre son identité à server.process dans un contexte de requête. Ce contexte suit ensuite chaque appel : vous le retrouvez dans respond, dans action, dans le store et dans les outils de l’agent.

Côté interface, l’option api.fetch remplace la fonction fetch utilisée par ChatKit. C’est là que vous ajoutez le jeton de session de votre application :

import { ChatKit, useChatKit } from '@openai/chatkit-react';

export function AssistantConnecte({ obtenirJeton }: { obtenirJeton: () => Promise<string> }) {
  const { control } = useChatKit({
    api: {
      url: '/chatkit',
      domainKey: 'local-dev',
      fetch: async (input, init) => {
        // Conserver les en-têtes posés par ChatKit, puis ajouter le nôtre
        const headers = new Headers(init?.headers);
        headers.set('Authorization', `Bearer ${await obtenirJeton()}`);
        return fetch(input, { ...init, headers });
      },
    },
  });

  return <ChatKit control={control} className="h-[600px] w-[360px]" />;
}

Côté serveur, une dépendance FastAPI vérifie le jeton et construit le contexte. La langue de l’interface arrive dans l’en-tête Accept-Language, que ChatKit envoie avec chaque requête :

from dataclasses import dataclass

from chatkit.server import StreamingResult
from chatkit.store import NotFoundError
from fastapi import Depends, FastAPI, HTTPException, Request
from fastapi.responses import Response, StreamingResponse


@dataclass
class RequestContext:
    user_id: str
    locale: str


async def utilisateur_courant(request: Request) -> str:
    jeton = request.headers.get("authorization", "").removeprefix("Bearer ")
    user_id = await verifier_jeton(jeton)  # votre authentification : session, JWT…
    if not user_id:
        raise HTTPException(status_code=401, detail="Non authentifié")
    return user_id


app = FastAPI()


@app.post("/chatkit")
async def chatkit_endpoint(request: Request, user_id: str = Depends(utilisateur_courant)) -> Response:
    contexte = RequestContext(user_id=user_id, locale=request.headers.get("accept-language", "fr"))
    try:
        result = await server.process(await request.body(), contexte)
    except NotFoundError:
        raise HTTPException(status_code=404, detail="Introuvable")
    if isinstance(result, StreamingResult):
        return StreamingResponse(result, media_type="text/event-stream")
    return Response(content=result.json, media_type="application/json")

À chacun ses conversations

Authentifier ne suffit pas : il faut aussi autoriser. Sans contrôle, un utilisateur qui devine l’identifiant d’un fil lirait la conversation d’un autre. Le bon endroit pour ce contrôle est le store, par lequel passe toute lecture. Dans cet exemple, chaque fil garde le nom de son propriétaire dans ses métadonnées, que le SDK ne transmet jamais à l’interface :

from chatkit.store import NotFoundError
from chatkit.types import Page, ThreadMetadata

from memory_store import MemoryStore


class StoreParUtilisateur(MemoryStore):
    async def save_thread(self, thread: ThreadMetadata, context: RequestContext) -> None:
        thread.metadata.setdefault("owner", context.user_id)
        await super().save_thread(thread, context)

    async def load_thread(self, thread_id: str, context: RequestContext) -> ThreadMetadata:
        thread = await super().load_thread(thread_id, context)
        if thread.metadata.get("owner") != context.user_id:
            raise NotFoundError(f"Thread {thread_id} not found")  # ne pas révéler qu'il existe
        return thread

    async def load_threads(self, limit, after, order, context: RequestContext) -> Page[ThreadMetadata]:
        page = await super().load_threads(limit, after, order, context)
        page.data = [t for t in page.data if t.metadata.get("owner") == context.user_id]
        return page

Le try du point d’accès prend ici son sens : sans lui, la lecture directe du fil d’un autre utilisateur finirait en erreur 500 au lieu d’un 404 ; pour une requête diffusée en flux, comme l’ajout d’un message, le SDK interrompt le flux sans rien envoyer. En production, ce filtre se fait dans la requête SQL elle-même (un WHERE owner = … couvre aussi correctement la pagination). Appliquez la même règle aux pièces jointes. La documentation rappelle aussi de décider ce que vous conservez et combien de temps : les fils contiennent du texte, des fichiers et des résultats d’outils, donc souvent des données personnelles.

Donner au modèle le contexte de l’application

Le modèle ne sait rien de la page que l’utilisateur consulte, de son abonnement ou du document ouvert. La documentation propose trois manières de le lui apprendre :

  • À chaque requête, par des en-têtes ajoutés dans api.fetch (X-Page, X-Plan…), que le serveur range dans le contexte ;
  • À la demande, par un outil serveur qui lit ctx.context.request_context et va chercher l’information ;
  • Dans le navigateur, par un outil côté client, quand l’information n’existe que là (voir la section suivante).

Une précaution, tirée du guide de mise en production : ne construisez jamais les instructions système du modèle à partir de valeurs qui viennent de l’utilisateur, même indirectement (titre de fil, objet d’un ticket). Gardez-les fixes, et faites passer le contexte variable comme une donnée.

Outils côté client

Certaines informations ne vivent que dans le navigateur : la sélection dans un éditeur, l’état d’un canevas, un brouillon non enregistré. Un outil côté client permet au modèle de les demander en plein tour : votre serveur met l’agent en pause, ChatKit exécute votre fonction dans le navigateur, puis renvoie le résultat au serveur, qui reprend.

Côté serveur, l’outil ne fait que poser la demande, et l’agent s’arrête sur lui :

from agents import Agent, RunContextWrapper, StopAtTools, function_tool
from chatkit.agents import AgentContext, ClientToolCall


@function_tool(description_override="Lit le texte que l'utilisateur a sélectionné dans la page.")
async def lire_selection(ctx: RunContextWrapper[AgentContext]) -> None:
    ctx.context.client_tool_call = ClientToolCall(name="lire_selection", arguments={})


assistant = Agent[AgentContext](
    name="Assistant rédaction",
    instructions="Aidez l'utilisateur à améliorer le texte qu'il sélectionne.",
    model="gpt-6.1-sol",
    tools=[lire_selection],
    tool_use_behavior=StopAtTools(stop_at_tool_names=[lire_selection.name]),
)

Côté interface, onClientTool reçoit la demande ; le nom doit correspondre, et l’objet renvoyé part au serveur :

const { control } = useChatKit({
  api: { url: '/chatkit', domainKey: 'local-dev' },
  onClientTool: async ({ name }) => {
    if (name === 'lire_selection') {
      return { texte: window.getSelection()?.toString() ?? '' };
    }
    return {};
  },
});

Un seul outil côté client peut s’exécuter par tour. Et quand vous n’attendez pas de réponse, préférez un effet : le serveur émet un ClientEffectEvent(name="actualiser_panier", data={...}), depuis respond, action ou un outil via ctx.context.stream(...), et l’interface le reçoit dans onEffect sans que la réponse s’interrompe. Les effets ne sont pas enregistrés dans le fil.

Piloter ChatKit depuis votre application

useChatKit renvoie, en plus de control, des méthodes pour commander l’interface depuis le reste de votre code :

MéthodeEffet
sendUserMessage({ text })Envoie un message comme si l’utilisateur l’avait tapé
setComposerValue({ text })Pré-remplit la zone de saisie sans envoyer
setThreadId(id)Ouvre un fil existant, ou un nouveau fil avec null
focusComposer()Place le curseur dans la zone de saisie
showHistory(), hideHistory()Ouvre ou ferme l’historique des conversations
sendCustomAction(action)Envoie une action au serveur (leçon 20)
fetchUpdates()Recharge le fil depuis le serveur

Un bouton « Demander à l’assistant » sur une fiche produit devient une ligne de code :

import { ChatKit, useChatKit } from '@openai/chatkit-react';

export function FicheProduit({ produit }: { produit: string }) {
  const chatkit = useChatKit({ api: { url: '/chatkit', domainKey: 'local-dev' } });

  return (
    <>
      <button onClick={() => chatkit.sendUserMessage({ text: `Le produit ${produit} est-il compatible avec mon équipement ?` })}>
        Demander à l'assistant
      </button>
      <ChatKit control={chatkit.control} className="h-[500px] w-[360px]" />
    </>
  );
}

L’option initialThread ouvre un fil précis au montage, par exemple celui que l’utilisateur avait laissé ouvert ; avec null (la valeur par défaut), ChatKit affiche l’écran d’accueil.

Observer ce qui se passe

ChatKit émet des événements : prêt (onReady), début et fin de réponse (onResponseStart, onResponseEnd), changement de fil (onThreadChange), journaux (onLog) et erreurs (onError). En React, ce sont des options de useChatKit :

const { control } = useChatKit({
  api: { url: '/chatkit', domainKey: 'local-dev' },
  onThreadChange: ({ threadId }) => memoriserDernierFil(threadId),
  onError: ({ error }) => envoyerTelemetrie({ nom: 'chatkit.error', message: error.message }),
  onLog: ({ name }) => envoyerTelemetrie({ nom: name }),
});

Le guide de mise en production insiste sur un point : ces événements peuvent contenir des données personnelles et le texte des messages. N’envoyez pas tout le contenu à votre outil de suivi : extrayez les champs utiles (nom de l’événement, identifiant de fil, code d’erreur). Côté serveur, journalisez chaque requête à /chatkit, les appels au store et au modèle, et les erreurs d’outils, avec l’identifiant de l’utilisateur et du fil, pour pouvoir rapprocher les deux côtés.

Pour les outils lents, tenez l’utilisateur informé : await ctx.context.stream(ProgressUpdateEvent(icon="search", text="Recherche dans vos commandes…")) affiche un état d’avancement immédiat, remplacé par la suite de la réponse et jamais enregistré dans le fil.

Avant la mise en production

La liste de contrôle, tirée du guide officiel :

  • Domaines : chaque nom d’hôte de production est inscrit sur la page Domain allowlist de la plateforme OpenAI, et sa clé est dans domainKey ;
  • Authentification et autorisation : chaque requête à /chatkit est authentifiée, les fils et pièces jointes sont filtrés par utilisateur ;
  • Secrets : la clé d’API et les identifiants internes sont dans des variables d’environnement ou un gestionnaire de secrets, jamais dans le dépôt ;
  • Entrées : les messages, les fichiers, les charges utiles d’actions et les résultats d’outils sont traités comme non fiables, et validés avant d’atteindre vos systèmes ;
  • Données personnelles : durée de conservation décidée et appliquée dans le store, chiffrement au repos, pas de données inutiles dans les métadonnées ;
  • Langue : la langue de Accept-Language pilote les réponses de l’agent et les messages de vos outils ;
  • Suivi : événements de l’interface et journaux du serveur collectés, sans contenu sensible.

Points clés à retenir

  • L’identité de l’utilisateur passe par api.fetch côté interface, puis par le contexte de server.process, qui la propage partout
  • L’autorisation se fait dans le store : chaque fil appartient à un utilisateur, et un fil d’autrui est « introuvable »
  • Le contexte de l’application arrive par des en-têtes, des outils serveur ou des outils côté client (onClientTool + StopAtTools) ; les effets (onEffect) servent quand aucune réponse n’est attendue
  • Les méthodes de useChatKit (sendUserMessage, setThreadId, setComposerValue…) commandent ChatKit depuis le reste de l’application
  • Les événements (onError, onLog…) alimentent votre suivi, sans y recopier de données personnelles ; la liste de contrôle de production couvre domaines, droits, secrets, entrées et données