Install any skill in seconds. Free to start, no credit card required.
Get Started Free →Build AI chat interfaces with custom backends, authentication, and context injection. Use when integrating chat UI with AI agents, adding auth to chat, injecting user/page context, or implementing httpOnly cookie proxies. Covers ChatKitServer, useChatKit, and MCP auth patterns. NOT when building simple chatbots without persistence or custom agent integration.
.claude/skills/aiskillstore-building-chat-interfaces/SKILL.md| Test case | Without → With | Effect | Δ tokens | Δ turns |
|---|---|---|---|---|
| case-01 | ✗→✓ | ▲ Improved | 10% | 0% |
| case-04 | ✗→✓ | ▲ Improved | 39% | 0% |
| case-06 | ✗→✓ | ▲ Improved | 75% | 0% |
| case-08 | ✗→✓ | ▲ Improved | 44% | 0% |
| case-09 | ✗→✓ | ▲ Improved | 5% | 0% |
Build production-grade AI chat interfaces with custom backend integration.
bash# Backend (Python) uv add chatkit-sdk agents httpx # Frontend (React) npm install @openai/chatkit-react
Frontend (React) Backend (Python)
┌─────────────────┐ ┌─────────────────┐
│ useChatKit() │───HTTP/SSE───>│ ChatKitServer │
│ - custom fetch │ │ - respond() │
│ - auth headers │ │ - store │
│ - page context │ │ - agent │
└─────────────────┘ └─────────────────┘pythonfrom chatkit.server import ChatKitServer from chatkit.agents import stream_agent_response from agents import Agent, Runner class CustomChatKitServer(ChatKitServer[RequestContext]): """Extend ChatKit server with custom agent.""" async def respond( self, thread: ThreadMetadata, input_user_message: UserMessageItem | None, context: RequestContext, ) -> AsyncIterator[ThreadStreamEvent]: if not input_user_message: return # Load conversation history previous_items = await self.store.load_thread_items( thread.id, after=None, limit=10, order="desc", context=context ) # Build history string for prompt history_str = "\n".join([ f"{item.role}: {item.content}" for item in reversed(previous_items.data) ]) # Extract context from metadata user_info = context.metadata.get('userInfo', {}) page_context = context.metadata.get('pageContext', {}) # Create agent with context in instructions agent = Agent( name="Assistant", tools=[your_search_tool], instructions=f"{history_str}\nUser: {user_info.get('name')}\n{system_prompt}", ) # Run agent with streaming result = Runner.run_streamed(agent, input_user_message.content) async for event in stream_agent_response(context, result): yield event
pythonfrom sqlmodel.ext.asyncio.session import AsyncSession from sqlalchemy.ext.asyncio import create_async_engine DATABASE_URL = os.getenv("DATABASE_URL").replace("postgresql://", "postgresql+asyncpg://") engine = create_async_engine(DATABASE_URL, pool_pre_ping=True) # Pre-warm connections on startup async def warmup_pool(): async with engine.begin() as conn: await conn.execute(text("SELECT 1"))
pythonfrom jose import jwt import httpx async def get_current_user(authorization: str = Header()): token = authorization.replace("Bearer ", "") async with httpx.AsyncClient() as client: jwks = (await client.get(JWKS_URL)).json() payload = jwt.decode(token, jwks, algorithms=["RS256"]) return payload
typescriptconst { control, sendUserMessage } = useChatKit({ api: { url: `${backendUrl}/chatkit`, domainKey: domainKey, // Custom fetch to inject auth and context fetch: async (url: string, options: RequestInit) => { if (!isLoggedIn) { throw new Error('User must be logged in'); } const pageContext = getPageContext(); const userInfo = { id: userId, name: user.name }; // Inject metadata into request body let modifiedOptions = { ...options }; if (modifiedOptions.body && typeof modifiedOptions.body === 'string') { const parsed = JSON.parse(modifiedOptions.body); if (parsed.params?.input) { parsed.params.input.metadata = { userId, userInfo, pageContext, ...parsed.params.input.metadata, }; modifiedOptions.body = JSON.stringify(parsed); } } return fetch(url, { ...modifiedOptions, headers: { ...modifiedOptions.headers, 'X-User-ID': userId, 'Content-Type': 'application/json', }, }); }, }, });
typescriptconst getPageContext = useCallback(() => { if (typeof window === 'undefined') return null; const metaDescription = document.querySelector('meta[name="description"]') ?.getAttribute('content') || ''; const mainContent = document.querySelector('article') || document.querySelector('main') || document.body; const headings = Array.from(mainContent.querySelectorAll('h1, h2, h3')) .slice(0, 5) .map(h => h.textContent?.trim()) .filter(Boolean) .join(', '); return { url: window.location.href, title: document.title, path: window.location.pathname, description: metaDescription, headings: headings, }; }, []);
typescriptconst [scriptStatus, setScriptStatus] = useState<'pending' | 'ready' | 'error'>( isBrowser && window.customElements?.get('openai-chatkit') ? 'ready' : 'pending' ); useEffect(() => { if (!isBrowser || scriptStatus !== 'pending') return; if (window.customElements?.get('openai-chatkit')) { setScriptStatus('ready'); return; } customElements.whenDefined('openai-chatkit').then(() => { setScriptStatus('ready'); }); }, []); // Only render when ready {isOpen && scriptStatus === 'ready' && <ChatKit control={control} />}
When auth tokens are in httpOnly cookies (can't be read by JavaScript):
typescript// app/api/chatkit/route.ts import { NextRequest, NextResponse } from "next/server"; import { cookies } from "next/headers"; export async function POST(request: NextRequest) { const cookieStore = await cookies(); const idToken = cookieStore.get("auth_token")?.value; if (!idToken) { return NextResponse.json({ error: "Not authenticated" }, { status: 401 }); } const response = await fetch(`${API_BASE}/chatkit`, { method: "POST", headers: { Authorization: `Bearer ${idToken}`, "Content-Type": "application/json", }, body: await request.text(), }); // Handle SSE streaming if (response.headers.get("content-type")?.includes("text/event-stream")) { return new Response(response.body, { status: response.status, headers: { "Content-Type": "text/event-stream", "Cache-Control": "no-cache", }, }); } return NextResponse.json(await response.json(), { status: response.status }); }
tsx// app/layout.tsx import Script from "next/script"; export default function RootLayout({ children }: { children: React.ReactNode }) { return ( <html lang="en"> <head> {/* MUST be beforeInteractive for web components */} <Script src="https://cdn.platform.openai.com/deployments/chatkit/chatkit.js" strategy="beforeInteractive" /> </head> <body>{children}</body> </html> ); }
MCP protocol doesn't forward auth headers. Pass credentials via system prompt:
pythonSYSTEM_PROMPT = """You are Assistant. ## Authentication Context - User ID: {user_id} - Access Token: {access_token} CRITICAL: When calling ANY MCP tool, include: - user_id: "{user_id}" - access_token: "{access_token}" """ # Format with credentials instructions = SYSTEM_PROMPT.format( user_id=context.user_id, access_token=context.metadata.get("access_token", ""), )
| 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 |
Run: python3 scripts/verify.py
Expected: ✓ building-chat-interfaces skill ready
--library-id /openai/chatkit --topic useChatKit| Case | Status | Duration (ms) | Turns | Tokens | Tool calls | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Without | With | Δ | Without | With | Δ | Without | With | Δ | Without | With | Δ | ||
case-01 | fail→pass | 23,877 | 13,051 | -45% | 1 | 1 | 0% | 3,608 | 3,977 | +10% | 0 | 0 | — |
case-02 | pass→pass | 25,990 | 17,052 | -34% | 1 | 1 | 0% | 5,011 | 5,525 | +10% | 0 | 0 | — |
case-03 | pass→pass | 17,318 | 10,630 | -39% | 1 | 1 | 0% | 3,296 | 4,591 | +39% | 0 | 0 | — |
case-04 | fail→pass | 22,618 | 14,813 | -35% | 1 | 1 | 0% | 2,896 | 4,037 | +39% | 0 | 0 | — |
case-05 | pass→pass | 11,781 | 9,666 | -18% | 1 | 1 | 0% | 1,824 | 3,171 | +74% | 0 | 0 | — |
case-06 | fail→pass | 12,120 | 11,501 | -5% | 1 | 1 | 0% | 2,073 | 3,621 | +75% | 0 | 0 | — |
case-07 | pass→pass | 13,793 | 13,172 | -5% | 1 | 1 | 0% | 2,261 | 3,815 | +69% | 0 | 0 | — |
case-08 | fail→pass | 20,371 | 9,862 | -52% | 1 | 1 | 0% | 2,271 | 3,275 | +44% | 0 | 0 | — |
case-09 | fail→pass | 17,210 | 6,146 | -64% | 1 | 1 | 0% | 3,352 | 3,519 | +5% | 0 | 0 | — |
case-10 | fail→pass | 11,250 | 11,437 | +2% | 1 | 1 | 0% | 2,048 | 3,564 | +74% | 0 | 0 | — |
case-11 | fail→pass | 12,642 | 6,135 | -51% | 1 | 1 | 0% | 2,191 | 3,479 | +59% | 0 | 0 | — |
case-12 | fail→pass | 21,084 | 5,210 | -75% | 1 | 1 | 0% | 2,486 | 3,302 | +33% | 0 | 0 | — |
case-13 | fail→pass | 16,693 | 1,977 | -88% | 1 | 1 | 0% | 1,740 | 2,714 | +56% | 0 | 0 | — |
case-14 | fail→pass | 33,174 | 7,845 | -76% | 1 | 1 | 0% | 2,931 | 2,805 | -4% | 0 | 0 | — |
case-15 | pass→pass | 4,357 | 2,330 | -47% | 1 | 1 | 0% | 700 | 2,804 | +301% | 0 | 0 | — |
case-16 | fail→pass | 17,033 | 9,468 | -44% | 1 | 1 | 0% | 2,340 | 3,279 | +40% | 0 | 0 | — |
case-17 | fail→pass | 11,181 | 7,813 | -30% | 1 | 1 | 0% | 1,734 | 2,906 | +68% | 0 | 0 | — |
case-18 | pass→pass | 7,727 | 8,844 | +14% | 1 | 1 | 0% | 1,285 | 3,116 | +142% | 0 | 0 | — |
case-19 | pass→pass | 12,891 | 26,232 | +103% | 1 | 1 | 0% | 2,238 | 4,298 | +92% | 0 | 0 | — |
case-20 | pass→pass | 27,623 | 3,961 | -86% | 1 | 1 | 0% | 2,392 | 3,063 | +28% | 0 | 0 | — |
case-21 | pass→pass | 20,907 | 12,097 | -42% | 1 | 1 | 0% | 2,644 | 3,718 | +41% | 0 | 0 | — |
case-22 | pass→pass | 29,261 | 17,951 | -39% | 1 | 1 | 0% | 2,574 | 4,832 | +88% | 0 | 0 | — |
case-23 | pass→pass | 6,958 | 8,769 | +26% | 1 | 1 | 0% | 1,130 | 3,122 | +176% | 0 | 0 | — |
case-24 | pass→pass | 12,566 | 6,621 | -47% | 1 | 1 | 0% | 1,453 | 3,637 | +150% | 0 | 0 | — |
case-25 | pass→pass | 10,453 | 3,340 | -68% | 1 | 1 | 0% | 873 | 2,989 | +242% | 0 | 0 | — |
DecimalAI ran this skill against gemini-3.6-flash twice over the same eval suite — once with the skill loaded and once without — and compared the two runs case by case. 25 cases were attempted. The headline lift of +48 percentage points is the difference between those two pass rates over the 25 comparable cases.
Without the skill loaded, the model failed this case. With it loaded, the same prompt on the same model passed. This is one improved case from the latest verified run; every case, including any that regressed, is in the table above.
Other measured skills in the registry, with their headline benchmark lift.