Building Chat Interfaces
Build production-grade AI chat interfaces with custom backend integration.
Quick Start
bash
1# Backend (Python)
2uv add chatkit-sdk agents httpx
3
4# Frontend (React)
5npm install @openai/chatkit-react
Core Architecture
Frontend (React) Backend (Python)
┌─────────────────┐ ┌─────────────────┐
│ useChatKit() │───HTTP/SSE───>│ ChatKitServer │
│ - custom fetch │ │ - respond() │
│ - auth headers │ │ - store │
│ - page context │ │ - agent │
└─────────────────┘ └─────────────────┘
Backend Patterns
1. ChatKit Server with Custom Agent
python
1from chatkit.server import ChatKitServer
2from chatkit.agents import stream_agent_response
3from agents import Agent, Runner
4
5class CustomChatKitServer(ChatKitServer[RequestContext]):
6 """Extend ChatKit server with custom agent."""
7
8 async def respond(
9 self,
10 thread: ThreadMetadata,
11 input_user_message: UserMessageItem | None,
12 context: RequestContext,
13 ) -> AsyncIterator[ThreadStreamEvent]:
14 if not input_user_message:
15 return
16
17 # Load conversation history
18 previous_items = await self.store.load_thread_items(
19 thread.id, after=None, limit=10, order="desc", context=context
20 )
21
22 # Build history string for prompt
23 history_str = "\n".join([
24 f"{item.role}: {item.content}"
25 for item in reversed(previous_items.data)
26 ])
27
28 # Extract context from metadata
29 user_info = context.metadata.get('userInfo', {})
30 page_context = context.metadata.get('pageContext', {})
31
32 # Create agent with context in instructions
33 agent = Agent(
34 name="Assistant",
35 tools=[your_search_tool],
36 instructions=f"{history_str}\nUser: {user_info.get('name')}\n{system_prompt}",
37 )
38
39 # Run agent with streaming
40 result = Runner.run_streamed(agent, input_user_message.content)
41 async for event in stream_agent_response(context, result):
42 yield event
2. Database Persistence
python
1from sqlmodel.ext.asyncio.session import AsyncSession
2from sqlalchemy.ext.asyncio import create_async_engine
3
4DATABASE_URL = os.getenv("DATABASE_URL").replace("postgresql://", "postgresql+asyncpg://")
5engine = create_async_engine(DATABASE_URL, pool_pre_ping=True)
6
7# Pre-warm connections on startup
8async def warmup_pool():
9 async with engine.begin() as conn:
10 await conn.execute(text("SELECT 1"))
3. JWT/JWKS Authentication
python
1from jose import jwt
2import httpx
3
4async def get_current_user(authorization: str = Header()):
5 token = authorization.replace("Bearer ", "")
6 async with httpx.AsyncClient() as client:
7 jwks = (await client.get(JWKS_URL)).json()
8 payload = jwt.decode(token, jwks, algorithms=["RS256"])
9 return payload
Frontend Patterns
1. Custom Fetch Interceptor
typescript
1const { control, sendUserMessage } = useChatKit({
2 api: {
3 url: `${backendUrl}/chatkit`,
4 domainKey: domainKey,
5
6 // Custom fetch to inject auth and context
7 fetch: async (url: string, options: RequestInit) => {
8 if (!isLoggedIn) {
9 throw new Error('User must be logged in');
10 }
11
12 const pageContext = getPageContext();
13 const userInfo = { id: userId, name: user.name };
14
15 // Inject metadata into request body
16 let modifiedOptions = { ...options };
17 if (modifiedOptions.body && typeof modifiedOptions.body === 'string') {
18 const parsed = JSON.parse(modifiedOptions.body);
19 if (parsed.params?.input) {
20 parsed.params.input.metadata = {
21 userId, userInfo, pageContext,
22 ...parsed.params.input.metadata,
23 };
24 modifiedOptions.body = JSON.stringify(parsed);
25 }
26 }
27
28 return fetch(url, {
29 ...modifiedOptions,
30 headers: {
31 ...modifiedOptions.headers,
32 'X-User-ID': userId,
33 'Content-Type': 'application/json',
34 },
35 });
36 },
37 },
38});
typescript
1const getPageContext = useCallback(() => {
2 if (typeof window === 'undefined') return null;
3
4 const metaDescription = document.querySelector('meta[name="description"]')
5 ?.getAttribute('content') || '';
6
7 const mainContent = document.querySelector('article') ||
8 document.querySelector('main') ||
9 document.body;
10
11 const headings = Array.from(mainContent.querySelectorAll('h1, h2, h3'))
12 .slice(0, 5)
13 .map(h => h.textContent?.trim())
14 .filter(Boolean)
15 .join(', ');
16
17 return {
18 url: window.location.href,
19 title: document.title,
20 path: window.location.pathname,
21 description: metaDescription,
22 headings: headings,
23 };
24}, []);
3. Script Loading Detection
typescript
1const [scriptStatus, setScriptStatus] = useState<'pending' | 'ready' | 'error'>(
2 isBrowser && window.customElements?.get('openai-chatkit') ? 'ready' : 'pending'
3);
4
5useEffect(() => {
6 if (!isBrowser || scriptStatus !== 'pending') return;
7
8 if (window.customElements?.get('openai-chatkit')) {
9 setScriptStatus('ready');
10 return;
11 }
12
13 customElements.whenDefined('openai-chatkit').then(() => {
14 setScriptStatus('ready');
15 });
16}, []);
17
18// Only render when ready
19{isOpen && scriptStatus === 'ready' && <ChatKit control={control} />}
Next.js Integration
httpOnly Cookie Proxy
When auth tokens are in httpOnly cookies (can't be read by JavaScript):
typescript
1// app/api/chatkit/route.ts
2import { NextRequest, NextResponse } from "next/server";
3import { cookies } from "next/headers";
4
5export async function POST(request: NextRequest) {
6 const cookieStore = await cookies();
7 const idToken = cookieStore.get("auth_token")?.value;
8
9 if (!idToken) {
10 return NextResponse.json({ error: "Not authenticated" }, { status: 401 });
11 }
12
13 const response = await fetch(`${API_BASE}/chatkit`, {
14 method: "POST",
15 headers: {
16 Authorization: `Bearer ${idToken}`,
17 "Content-Type": "application/json",
18 },
19 body: await request.text(),
20 });
21
22 // Handle SSE streaming
23 if (response.headers.get("content-type")?.includes("text/event-stream")) {
24 return new Response(response.body, {
25 status: response.status,
26 headers: {
27 "Content-Type": "text/event-stream",
28 "Cache-Control": "no-cache",
29 },
30 });
31 }
32
33 return NextResponse.json(await response.json(), { status: response.status });
34}
Script Loading Strategy
tsx
1// app/layout.tsx
2import Script from "next/script";
3
4export default function RootLayout({ children }: { children: React.ReactNode }) {
5 return (
6 <html lang="en">
7 <head>
8 {/* MUST be beforeInteractive for web components */}
9 <Script
10 src="https://cdn.platform.openai.com/deployments/chatkit/chatkit.js"
11 strategy="beforeInteractive"
12 />
13 </head>
14 <body>{children}</body>
15 </html>
16 );
17}
MCP protocol doesn't forward auth headers. Pass credentials via system prompt:
python
1SYSTEM_PROMPT = """You are Assistant.
2
3## Authentication Context
4- User ID: {user_id}
5- Access Token: {access_token}
6
7CRITICAL: When calling ANY MCP tool, include:
8- user_id: "{user_id}"
9- access_token: "{access_token}"
10"""
11
12# Format with credentials
13instructions = SYSTEM_PROMPT.format(
14 user_id=context.user_id,
15 access_token=context.metadata.get("access_token", ""),
16)
Common Pitfalls
| Issue | Symptom | Fix |
|---|
| History not in prompt | Agent doesn't remember conversation | Include history as string in system prompt |
| Context not transmitted | Agent missing user/page info | Add to request metadata, extract in backend |
| Script not loaded | Component fails to render | Detect script loading, wait before rendering |
| Auth headers missing | Backend rejects requests | Use custom fetch interceptor |
| httpOnly cookies | Can't read token from JS | Create server-side API route proxy |
| First request slow | 7+ second delay | Pre-warm database connection pool |
Verification
Run: python3 scripts/verify.py
Expected: ✓ building-chat-interfaces skill ready
If Verification Fails
- Check: references/ folder has chatkit-integration-patterns.md
- Stop and report if still failing
- streaming-llm-responses - Tier 2: Response lifecycle, progress updates, client effects
- building-chat-widgets - Tier 3: Interactive widgets, entity tagging, composer tools
- fetching-library-docs - ChatKit docs:
--library-id /openai/chatkit --topic useChatKit
References