Intégration avancée dans votre app
Sur cette page
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 Pythonopenai-chatkit1.6.5 (Prepare your app for production, Pass extra app context, Update the client during a response) et les types de@openai/chatkit-react1.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_contextet 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éthode | Effet |
|---|---|
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 à
/chatkitest 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-Languagepilote 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.fetchcôté interface, puis par le contexte deserver.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