Advanced integration in your app
On this page
Lesson rewritten November 3, 2026. The previous version relied on invented properties and events (
ChatProvider, “sidebar” mode, UI-based persistence). It now follows the Python SDKopenai-chatkit1.6.5 guides (Prepare your app for production, Pass extra app context, Update the client during a response) and@openai/chatkit-react1.6.1 types: the server code has been executed locally, and the React code verified by compilation. Sources: Prepare your app for production · Pass extra app context to your model · Update the client during a response · Advanced integrations with ChatKit.
What’s left after the demo
The previous four lessons give you a working assistant. To make it live in a real product, it needs what separates a demo from a service: knowing who is talking, showing each user only their conversations, interacting with the rest of the application, and being observable. This lesson covers these points one by one.
Authenticate every request
The SDK makes no assumptions about your authentication: it lets you verify the user, then pass their identity to server.process in a request context. This context follows every call: you find it in respond, in action, in the store, and in the agent’s tools.
On the interface side, the api.fetch option replaces the fetch function used by ChatKit. This is where you add your application’s session token:
import { ChatKit, useChatKit } from '@openai/chatkit-react';
export function ConnectedAssistant({ obtainToken }: { obtainToken: () => Promise<string> }) {
const { control } = useChatKit({
api: {
url: '/chatkit',
domainKey: 'local-dev',
fetch: async (input, init) => {
// Keep headers set by ChatKit, then add ours
const headers = new Headers(init?.headers);
headers.set('Authorization', `Bearer ${await obtainToken()}`);
return fetch(input, { ...init, headers });
},
},
});
return <ChatKit control={control} className="h-[600px] w-[360px]" />;
}
On the server side, a FastAPI dependency verifies the token and builds the context. The interface language arrives in the Accept-Language header, which ChatKit sends with every request:
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 current_user(request: Request) -> str:
token = request.headers.get("authorization", "").removeprefix("Bearer ")
user_id = await verify_token(token) # your authentication: session, JWT...
if not user_id:
raise HTTPException(status_code=401, detail="Unauthenticated")
return user_id
app = FastAPI()
@app.post("/chatkit")
async def chatkit_endpoint(request: Request, user_id: str = Depends(current_user)) -> Response:
context = RequestContext(user_id=user_id, locale=request.headers.get("accept-language", "fr"))
try:
result = await server.process(await request.body(), context)
except NotFoundError:
raise HTTPException(status_code=404, detail="Not found")
if isinstance(result, StreamingResult):
return StreamingResponse(result, media_type="text/event-stream")
return Response(content=result.json, media_type="application/json")
Each user gets their own conversations
Authentication isn’t enough: you also need authorization. Without control, a user who guesses a thread ID could read someone else’s conversation. The right place for this control is the store, through which all reads pass. In this example, each thread keeps its owner’s name in its metadata, which the SDK never transmits to the interface:
from chatkit.store import NotFoundError
from chatkit.types import Page, ThreadMetadata
from memory_store import MemoryStore
class UserStore(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") # do not reveal that it exists
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
The try block in the endpoint makes sense here: without it, directly reading another user’s thread would end in a 500 error instead of a 404; for a streamed request, like adding a message, the SDK interrupts the stream without sending anything. In production, this filtering happens in the SQL query itself (a WHERE owner = … also correctly handles pagination). Apply the same rule to attachments. The documentation also reminds you to decide what you keep and for how long: threads contain text, files, and tool results, so often personal data.
Give the model your app context
The model knows nothing about the page the user is viewing, their subscription, or the open document. The documentation proposes three ways to teach it:
- On every request, via headers added in
api.fetch(X-Page,X-Plan…), which the server stores in the context; - On demand, via a server tool that reads
ctx.context.request_contextand fetches the information; - In the browser, via a client-side tool, when the information exists only there (see the next section).
One precaution, taken from the production guide: never build the model’s system instructions from values that come from the user, even indirectly (thread title, ticket subject). Keep them fixed, and pass the variable context as data.
Client-side tools
Some information lives only in the browser: selection in an editor, canvas state, an unsaved draft. A client-side tool lets the model request them mid-turn: your server pauses the agent, ChatKit executes your function in the browser, then returns the result to the server, which resumes.
On the server side, the tool only makes the request, and the agent stops on it:
from agents import Agent, RunContextWrapper, StopAtTools, function_tool
from chatkit.agents import AgentContext, ClientToolCall
@function_tool(description_override="Reads the text the user has selected on the page.")
async def read_selection(ctx: RunContextWrapper[AgentContext]) -> None:
ctx.context.client_tool_call = ClientToolCall(name="read_selection", arguments={})
assistant = Agent[AgentContext](
name="Writing Assistant",
instructions="Help the user improve the text they select.",
model="gpt-6.1-sol",
tools=[read_selection],
tool_use_behavior=StopAtTools(stop_at_tool_names=[read_selection.name]),
)
On the interface side, onClientTool receives the request; the name must match, and the returned object goes to the server:
const { control } = useChatKit({
api: { url: '/chatkit', domainKey: 'local-dev' },
onClientTool: async ({ name }) => {
if (name === 'read_selection') {
return { text: window.getSelection()?.toString() ?? '' };
}
return {};
},
});
Only one client-side tool can execute per turn. And when you don’t expect a response, prefer an effect: the server emits a ClientEffectEvent(name="refresh_cart", data={...}), from respond, action, or a tool via ctx.context.stream(...), and the interface receives it in onEffect without interrupting the response. Effects are not recorded in the thread.
Drive ChatKit from your application
useChatKit returns, in addition to control, methods to command the interface from the rest of your code:
| Method | Effect |
|---|---|
sendUserMessage({ text }) | Sends a message as if the user typed it |
setComposerValue({ text }) | Pre-fills the input area without sending |
setThreadId(id) | Opens an existing thread, or a new thread with null |
focusComposer() | Places the cursor in the input area |
showHistory(), hideHistory() | Opens or closes the conversation history |
sendCustomAction(action) | Sends an action to the server (Lesson 20) |
fetchUpdates() | Reloads the thread from the server |
An “Ask the assistant” button on a product sheet becomes one line of code:
import { ChatKit, useChatKit } from '@openai/chatkit-react';
export function ProductSheet({ product }: { product: string }) {
const chatkit = useChatKit({ api: { url: '/chatkit', domainKey: 'local-dev' } });
return (
<>
<button onClick={() => chatkit.sendUserMessage({ text: `Is the ${product} product compatible with my equipment?` })}>
Ask the assistant
</button>
<ChatKit control={chatkit.control} className="h-[500px] w-[360px]" />
</>
);
}
The initialThread option opens a specific thread on mount, for example the one the user left open; with null (the default value), ChatKit displays the home screen.
Observe what’s happening
ChatKit emits events: ready (onReady), response start and end (onResponseStart, onResponseEnd), thread change (onThreadChange), logs (onLog), and errors (onError). In React, these are options for useChatKit:
const { control } = useChatKit({
api: { url: '/chatkit', domainKey: 'local-dev' },
onThreadChange: ({ threadId }) => rememberLastThread(threadId),
onError: ({ error }) => sendTelemetry({ name: 'chatkit.error', message: error.message }),
onLog: ({ name }) => sendTelemetry({ name: name }),
});
The production guide insists on one point: these events can contain personal data and message text. Don’t send all content to your tracking tool: extract useful fields (event name, thread ID, error code). On the server side, log every request to /chatkit, calls to the store and model, and tool errors, with the user and thread ID, so you can correlate both sides.
For slow tools, keep the user informed: await ctx.context.stream(ProgressUpdateEvent(icon="search", text="Searching your orders…")) displays immediate progress, replaced by the rest of the response and never recorded in the thread.
Before going to production
The checklist, taken from the official guide:
- Domains: every production hostname is listed on the Domain allowlist page of the OpenAI platform, and its key is in
domainKey; - Authentication and authorization: every request to
/chatkitis authenticated, threads and attachments are filtered by user; - Secrets: the API key and internal credentials are in environment variables or a secrets manager, never in the repository;
- Inputs: messages, files, action payloads, and tool results are treated as untrusted, and validated before reaching your systems;
- Personal data: retention period decided and applied in the store, encryption at rest, no unnecessary data in metadata;
- Language: the
Accept-Languagelanguage drives the agent’s responses and your tool messages; - Tracking: interface events and server logs collected, without sensitive content.
Key takeaways
- The user’s identity passes via
api.fetchon the interface side, then via the context ofserver.process, which propagates it everywhere - Authorization happens in the store: each thread belongs to a user, and another user’s thread is “not found”
- App context arrives via headers, server tools, or client-side tools (
onClientTool+StopAtTools); effects (onEffect) serve when no response is expected - Methods from
useChatKit(sendUserMessage,setThreadId,setComposerValue…) command ChatKit from the rest of the application - Events (
onError,onLog…) feed your tracking, without copying personal data into it; the production checklist covers domains, rights, secrets, inputs, and data