Skip to main content
Lesson 21 of 25 · Building OpenAI Agents

Advanced integration in your app

Lesson 21 of 25 Updated November 3, 2026 Developer
On this page
  1. What’s left after the demo
  2. Authenticate every request
  3. Each user gets their own conversations
  4. Give the model your app context
  5. Client-side tools
  6. Drive ChatKit from your application
  7. Observe what’s happening
  8. Before going to production
  9. Key takeaways

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 SDK openai-chatkit 1.6.5 guides (Prepare your app for production, Pass extra app context, Update the client during a response) and @openai/chatkit-react 1.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_context and 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:

MethodEffect
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 /chatkit is 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-Language language 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.fetch on the interface side, then via the context of server.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