AI
Tutorial OpenAI ChatKit: Chat Agent Self-Hosted di React
Oktober 202612 menit baca

ChatKit adalah antarmuka chat dari OpenAI yang bisa di-embed untuk pengalaman agent. Di dalamnya sudah ada widget, tampilan pemanggilan tool, attachment file, dan visualisasi chain-of-thought, jadi Anda cukup mengonfigurasi UI chat alih-alih membangunnya. Di React, ChatKit dirender dengan komponen ChatKit dan hook useChatKit dari @openai/chatkit-react.
Instal server Python dengan pip install openai-chatkit, buat subclass ChatKitServer, lalu override respond untuk menjalankan agent Agents SDK lewat stream_agent_response. Sediakan satu endpoint POST yang meneruskan request body ke server.process, dan arahkan opsi api.url di useChatKit ke endpoint itu. Anda juga perlu mengimplementasikan Store untuk thread dan item.
Ya. OpenAI menyatakan Agent Builder dijadwalkan berhenti pada 30 November 2026, tetapi ChatKit tetap tersedia. Jalur hosted yang menghubungkan ChatKit ke workflow Agent Builder hanya untuk pengguna lama selama masa transisi, dan pekerjaan baru sebaiknya memakai advanced integration dengan agent di server sendiri.
UI-nya dimuat dari CDN OpenAI dan iframe memverifikasi domain key Anda ke api.openai.com saat dimuat. Panduan production ChatKit juga menyebut iframe mengirim telemetry sendiri ke endpoint yang dikendalikan OpenAI, yang menurut panduan itu tidak berisi PII atau isi pesan. Thread, file, dan agent Anda tetap berjalan di server sendiri.
Attachment nonaktif secara default, jadi atur composer.attachments.enabled ke true dan pilih uploadStrategy two_phase atau direct di bawah opsi api. Di server, teruskan sebuah AttachmentStore ke ChatKitServer; dengan two_phase, create_attachment mengembalikan URL upload yang dipakai browser. Konversi file tersimpan menjadi input model dilakukan di ThreadItemConverter.

Ringkasan Utama
OpenAI ChatKit adalah UI chat React yang bisa di-embed. Dalam mode self-hosted, UI ini berbicara dengan endpoint ChatKitServer Python milik Anda sendiri, yang menjalankan agent Agents SDK dan men-stream balasan, widget, serta event progress. Autentikasi, Store untuk thread, dan penyimpanan attachment tetap tanggung jawab Anda, dan UI-nya sendiri dimuat dari CDN OpenAI dengan domain key yang terdaftar.
Widget chat di situs ini terdiri dari 826 baris React di Chatbot.tsx dan 416 baris lagi di API route-nya. Sebagian besar isinya adalah plumbing yang tidak pernah dilihat pengunjung: membaca response body dengan getReader dan TextDecoder, membatalkan request dengan AbortController saat panel ditutup, dan menyimpan 20 pesan terakhir di localStorage karena tidak ada thread store di sisi server. Jadi ketika OpenAI memposisikan ChatKit sebagai cara membangun chat agent tanpa membuat ulang UI chat, pertanyaan saya sederhana: baris mana yang benar-benar bisa dihapus, dan baris baru apa yang harus ditambahkan.
Tutorial OpenAI ChatKit ini membangun versi self-hosted dari awal sampai akhir: @openai/chatkit-react di front end, server ChatKit Python yang open source di back end, dan agent Agents SDK di belakangnya, dengan contoh meja layanan status order ERP. Inilah jalur yang sekarang direkomendasikan OpenAI, karena Agent Builder, yang menjadi dasar opsi ChatKit hosted, dijadwalkan berhenti beroperasi pada 30 November 2026. Semua nama API di bawah berasal dari panduan ChatKit OpenAI, repository chatkit-python beserta panduan production-nya, atau advanced samples resmi.
ChatKit punya dua jalur integrasi. Jalur hosted membuat session terhadap workflow Agent Builder lalu memberi browser sebuah client secret; panduan OpenAI kini membatasinya untuk tim yang sudah punya workflow tersebut, selama masa transisi. Jalur lainnya, yang di dokumentasi disebut advanced atau custom server integration, adalah yang dibangun di post ini. Istilah self-hosted sebenarnya agak menyesatkan, karena hanya berlaku untuk back end. Ada tiga bagian, dan Anda menjalankan dua di antaranya:
Keuntungan pembagian ini adalah agent, data, dan pemanggilan model tetap berada di infrastruktur Anda; panduan OpenAI menyebut autentikasi custom, data residency, dan deployment on-premises sebagai alasan memilihnya. Harganya, semua yang biasanya dikerjakan produk hosted, yaitu login, tenancy, retensi, dan penyimpanan file, kini menjadi kode yang Anda tulis sendiri. Sisa post ini adalah kode tersebut, sesuai urutan yang akan saya tulis.
Instal server dengan pip install openai-chatkit, yang ikut menarik package openai-agents sebagai dependency. ChatKitServer bersifat generic terhadap tipe context pilihan Anda, dan satu-satunya method yang wajib di-override adalah respond, sebuah async generator yang menerima metadata thread dan pesan user terbaru lalu meng-yield thread stream event. Modul chatkit.agents menyediakan jembatannya: simple_to_agent_input mengubah item thread yang tersimpan menjadi input Agents SDK, dan stream_agent_response mengubah run yang di-stream menjadi event ChatKit, termasuk progress tool dan widget yang dikirim tool Anda.
# server.py
import os
from typing import AsyncIterator
from agents import Agent, RunContextWrapper, Runner, function_tool
from chatkit.agents import AgentContext, simple_to_agent_input, stream_agent_response
from chatkit.server import ChatKitServer
from chatkit.types import ThreadMetadata, ThreadStreamEvent, UserMessageItem
from .context import RequestContext # your own dataclass: user_id, tenant_id, locale
from .erp import fetch_order # your own read-only service call
@function_tool(description_override="Look up one sales order by its number.")
async def get_order_status(ctx: RunContextWrapper[AgentContext], order_no: str) -> dict:
# request_context is whatever the endpoint passed to server.process(), so the
# authenticated user travels with the run and the tool can scope its query.
user: RequestContext = ctx.context.request_context
await ctx.context.stream_progress(icon="document", text=f"Looking up {order_no}")
return await fetch_order(order_no, tenant_id=user.tenant_id)
order_desk = Agent[AgentContext](
name="Order desk",
instructions="Answer questions about the user's own sales orders. "
"Always call the tool; never guess a status.",
model=os.environ["CHATKIT_MODEL"],
tools=[get_order_status],
)
class OrderDeskServer(ChatKitServer[RequestContext]):
async def respond(
self,
thread: ThreadMetadata,
input_user_message: UserMessageItem | None,
context: RequestContext,
) -> AsyncIterator[ThreadStreamEvent]:
# Wrong: order="asc", limit=20 returns the FIRST 20 items. Once a thread
# passes 20 items, the message the user just sent is not in the page.
# Right: take the newest 20, then flip them back into chronological order.
page = await self.store.load_thread_items(
thread.id, after=None, limit=20, order="desc", context=context
)
input_items = await simple_to_agent_input(list(reversed(page.data)))
agent_context = AgentContext(thread=thread, store=self.store, request_context=context)
result = Runner.run_streamed(order_desk, input_items, context=agent_context)
async for event in stream_agent_response(agent_context, result):
yield eventDua detail di blok itu lebih penting daripada kelihatannya. Nama model diambil dari konfigurasi karena ChatKit tidak mengikat Anda ke satu model; panduannya menyebut server bisa terhubung ke layanan agentic apa pun, dan respond hanyalah generator, jadi bisa saja memanggil provider lain. Selain itu, tool membaca user yang sudah terautentikasi dari ctx.context.request_context, yaitu objek yang sama yang diteruskan lapisan HTTP Anda. Dengan cara inilah tool membatasi query ERP ke tenant pemanggil tanpa model pernah diberi tahu tenant id-nya.
Dua snippet resmi OpenAI berbeda dalam cara memuat history. Panduan custom integration memuat 20 item terbaru dengan urutan descending lalu membaliknya; quickstart chatkit-python memuat 20 item dengan urutan ascending tanpa cursor. Cara kedua mengembalikan 20 item tertua, sehingga begitu thread melewati 20 item, pesan baru user diam-diam hilang dari input model. Gunakan urutan descending lalu balik, seperti di atas.
ChatKit mengarahkan semua jenis request, seperti membuat thread, mengirim pesan, menampilkan daftar thread, atau menjalankan action widget, lewat satu endpoint. ChatKitServer.process menerima raw body dan sebuah objek context, dan context itulah satu-satunya jalur identitas menuju store dan tool Anda. Panduan production menyatakan dengan jelas bahwa Python SDK mengharapkan aplikasi Anda yang menangani autentikasi. Polanya: autentikasi dengan mekanisme yang sudah dipakai framework Anda, buat context bertipe berisi user id, tenant, dan locale, lalu teruskan ke process.
# main.py — one endpoint; ChatKitServer routes every request type internally
from fastapi import Depends, FastAPI, HTTPException, Request, Response
from fastapi.responses import StreamingResponse
from chatkit.server import StreamingResult
from .auth import verify_jwt # your existing session or JWT check
from .context import RequestContext
from .server import OrderDeskServer
from .store import PostgresStore
app = FastAPI()
server = OrderDeskServer(store=PostgresStore())
async def current_user(request: Request) -> RequestContext:
claims = verify_jwt(request.headers.get("authorization", ""))
if claims is None:
# ChatKitServer does no authentication of its own. Skip this and the
# endpoint is an open, metered proxy to your model account.
raise HTTPException(status_code=401, detail="Unauthorized")
return RequestContext(
user_id=claims["sub"],
tenant_id=claims["tenant"],
# The ChatKit client sends exactly one locale in Accept-Language.
locale=request.headers.get("accept-language", "en"),
)
@app.post("/api/chatkit")
async def chatkit_endpoint(request: Request, ctx: RequestContext = Depends(current_user)):
result = await server.process(await request.body(), ctx)
if isinstance(result, StreamingResult):
return StreamingResponse(result, media_type="text/event-stream")
return Response(content=result.json, media_type="application/json")Locale didapat tanpa usaha tambahan: client ChatKit mengirim satu locale di header Accept-Language pada setiap request, default-nya bahasa browser kecuali Anda meng-override opsi locale. Untuk audiens bilingual seperti pembaca saya, header inilah yang menentukan apakah output tool dan pesan error kembali dalam bahasa Inggris atau Indonesia, dan panduan production menunjukkan pola gettext untuk itu. Hal lain yang diminta panduan tersebut: instruksi system harus statis dan tidak pernah memuat nilai dari user seperti judul thread, karena itu jalur prompt injection.
chatkit.store.Store adalah abstract class dengan dua belas method: load, save, list, dan delete untuk thread; add, save, load, list, dan delete untuk item thread; serta save, load, dan delete untuk attachment. Semuanya menerima objek context, dan justru itu intinya. Store in-memory di quickstart cukup untuk demo tetapi salah untuk apa pun yang dipakai bersama, karena load_threads-nya mengembalikan semua thread di dictionary tanpa peduli siapa yang meminta. Saran panduan untuk production adalah database yang durable dengan model disimpan sebagai JSON blob, agar upgrade library yang menambah field tidak memerlukan schema migration.
# store.py — two of the Store methods, showing where the tenant check lives
from chatkit.store import NotFoundError, Store
from chatkit.types import Page, ThreadMetadata
from .context import RequestContext
from .db import pool # an asyncpg pool
class PostgresStore(Store[RequestContext]):
async def load_thread(self, thread_id: str, context: RequestContext) -> ThreadMetadata:
row = await pool.fetchrow(
"SELECT data FROM chatkit_threads WHERE id = $1 AND user_id = $2",
thread_id, context.user_id,
)
if row is None:
# Same error for "missing" and "someone else's": never confirm that
# a thread id exists to a user who does not own it.
raise NotFoundError(f"Thread {thread_id} not found")
# Stored as a JSON blob, so a library upgrade that adds fields
# needs no migration.
return ThreadMetadata.model_validate_json(row["data"])
async def load_threads(self, limit, after, order, context) -> Page[ThreadMetadata]:
# Wrong (the quickstart's in-memory store): list(self.threads.values())
# returns every user's history to whoever opens the thread list.
rows = await pool.fetch(
f"SELECT id, data FROM chatkit_threads WHERE user_id = $1 "
f"ORDER BY created_at {'DESC' if order == 'desc' else 'ASC'} LIMIT $2",
context.user_id, limit + 1,
)
... # apply the "after" cursor, build Page(data=..., has_more=..., after=...)Saya memperlakukan Store sebagai batas keamanan yang sesungguhnya untuk seluruh fitur ini. Endpoint menentukan siapa Anda; Store menentukan apa yang boleh Anda lihat. Jika load_thread tidak memeriksa kepemilikan, user mana pun yang tahu atau menebak sebuah thread id bisa membaca percakapan user lain, termasuk apa pun yang dikembalikan tool ERP ke dalamnya. Retensi juga tempatnya di sini: panduan production menyarankan mengimplementasikannya di Store, misalnya menghapus thread yang lebih tua dari sejumlah hari tertentu, karena thread berisi teks user, attachment, dan output tool.
Instal binding-nya dengan npm install @openai/chatkit-react dan muat chatkit.js dari CDN OpenAI. Untuk custom server, opsi api menerima url sebagai pengganti callback getClientSecret milik jalur hosted, ditambah domainKey, override fetch yang opsional, dan upload strategy. Override fetch adalah cara paling rapi untuk menyisipkan bearer token Anda, karena ChatKit memanggilnya untuk setiap request. Theme, prompt di start screen, dan pengaturan composer semuanya ada di objek opsi yang sama.
"use client";
// components/OrderDeskChat.tsx (Next.js App Router)
import Script from "next/script";
import { ChatKit, useChatKit } from "@openai/chatkit-react";
export function OrderDeskChat({ getToken }: { getToken: () => Promise<string> }) {
const { control } = useChatKit({
api: {
url: "/api/chatkit",
domainKey: process.env.NEXT_PUBLIC_CHATKIT_DOMAIN_KEY ?? "domain_pk_localhost_dev",
// Called for every ChatKit request, so the bearer token is always current.
fetch: async (input, init) => {
const headers = new Headers(init?.headers);
headers.set("Authorization", `Bearer ${await getToken()}`);
return fetch(input, { ...init, headers });
},
// Lives under api, not composer, in the published types.
uploadStrategy: { type: "two_phase" },
},
theme: {
colorScheme: "dark",
color: { accent: { primary: "#22d3ee", level: 1 } },
radius: "round",
density: "compact",
},
startScreen: {
greeting: "Ask about any of your sales orders",
prompts: [
{ label: "Late orders", prompt: "Which of my orders are past their delivery date?", icon: "search" },
],
},
composer: {
placeholder: "Order number or question",
attachments: {
enabled: true, // off by default
maxSize: 5 * 1024 * 1024, // default is 100 MB, far too generous
maxCount: 3, // default is 10
accept: { "application/pdf": [".pdf"], "image/*": [".png", ".jpg"] },
},
},
onError: ({ error }) => console.error("ChatKit error", error),
});
return (
<>
<Script src="https://cdn.platform.openai.com/deployments/chatkit/chatkit.js" strategy="afterInteractive" />
<ChatKit control={control} className="h-[600px] w-full" />
</>
);
}Opsi theme mencakup color scheme, warna dan level accent, tint grayscale, radius sudut dari pill sampai sharp, density dari compact sampai spacious, serta typography. Itu sudah cukup bagi saya untuk menyamakan dengan palet gelap situs ini memakai satu warna accent. Batas attachment sebaiknya diatur secara eksplisit: type yang dipublikasikan memakai default 100 MB per file dan 10 file per pesan, terlalu longgar untuk chat yang meneruskan file ke model.
Percayai type TypeScript, bukan contoh di prosa. Panduan theming menampilkan attachment dengan uploadStrategy di dalam composer dan tanpa flag enabled, padahal definisi type @openai/chatkit menaruh uploadStrategy di bawah api dan menjadikan composer.attachments.enabled sebagai saklarnya, dengan default false. Di project Next.js App Router, komponennya juga butuh directive use client, karena useChatKit adalah hook.
Widget adalah titik di mana ChatKit benar-benar unggul dibanding UI buatan sendiri. Anda mendesain card, list, atau form secara visual di widgets.chatkit.studio, mempratinjaunya dengan data contoh, mengekspor file .widget, lalu meng-commit-nya di samping kode server. Di server, WidgetTemplate.from_file memuatnya dan build mengisi placeholder-nya; tool kemudian bisa memanggil ctx.context.stream_widget, dan stream_agent_response meneruskan widget itu ke client. Tombol dan kontrol form membawa action dengan type dan payload, yang sampai ke method action di ChatKitServer Anda tanpa user perlu mengetik pesan.
# widgets.py — a card designed in widgets.chatkit.studio, exported as a .widget file
from datetime import datetime
from agents import RunContextWrapper, function_tool
from chatkit.agents import AgentContext
from chatkit.types import AssistantMessageContent, AssistantMessageItem, ThreadItemDoneEvent
from chatkit.widgets import WidgetTemplate
from .erp import create_cancel_request, fetch_order
order_card = WidgetTemplate.from_file("widgets/order_card.widget")
@function_tool(description_override="Show an order as a card with its lines and status.")
async def show_order_card(ctx: RunContextWrapper[AgentContext], order_no: str) -> str:
order = await fetch_order(order_no, tenant_id=ctx.context.request_context.tenant_id)
# The template's {{ }} placeholders are filled here, server-side, then streamed.
await ctx.context.stream_widget(order_card.build(order))
return f"Displayed order {order_no}." # what the model sees, not the user
# On OrderDeskServer: the card's "Request cancellation" button carries
# onClickAction = { type: "order.request_cancel", payload: { order_no } }
async def action(self, thread, action, sender, context):
if action.type != "order.request_cancel":
return
# Draft, never post: the button files a request that a human approves.
await create_cancel_request(action.payload["order_no"], requested_by=context.user_id)
yield ThreadItemDoneEvent(
item=AssistantMessageItem(
thread_id=thread.id,
id=self.store.generate_item_id("message", thread, context),
created_at=datetime.now(),
content=[AssistantMessageContent(text="Cancellation request filed for approval.")],
)
)Di lingkungan ERP saya memegang satu aturan untuk action: tombol boleh membuat draft atau request, tidak pernah dokumen yang sudah di-posting. Tombol pembatalan di atas mengajukan request yang harus disetujui seseorang, sehingga permukaan chat tetap berada di luar rantai approval. Action juga bisa diarahkan ke browser dengan mengatur handler ke client, cocok untuk pekerjaan UI murni seperti membuka record di panel lain. Jika sebuah field teks di widget harus ter-stream saat dihasilkan, beri node Text atau Markdown itu sebuah id; panduan widget mencatat bahwa hanya node tersebut yang men-stream teksnya, sedangkan perubahan lain dirender ulang.
Attachment membutuhkan AttachmentStore yang diteruskan ke constructor ChatKitServer. Dengan strategi two_phase, client meminta server Anda membuat attachment, create_attachment mengembalikan upload descriptor berisi URL, lalu browser meng-upload ke sana, yang bisa berupa signed URL di object storage Anda. Strategi direct mengirim file ke uploadUrl yang Anda tentukan. Strategi hosted hanya untuk jalur Agent Builder. Mengirim isi file ke model adalah langkah terpisah: sample customer-support resmi membuat subclass ThreadItemConverter dan meng-override attachment_to_message_content untuk mengubah gambar tersimpan menjadi bagian input_image.
Domain key adalah langkah terakhir dan yang paling sering merusak deploy. Anda mendaftarkan setiap hostname production di domain allowlist pada pengaturan security organisasi OpenAI, lalu menyalin key yang dihasilkan ke opsi domainKey. Saat dimuat, iframe ChatKit memanggil api.openai.com untuk memverifikasinya, dan jika key tidak ada atau tidak valid, ChatKit menolak untuk dirender. Sample resmi berjalan di lokal dengan key placeholder, jadi hostname staging yang tidak pernah didaftarkan adalah tempat pertama masalah ini muncul.
Meng-host back end sendiri tidak menghilangkan OpenAI dari browser. UI diambil dari CDN OpenAI, iframe memverifikasi domain key ke api.openai.com, dan panduan production menyebut iframe mengirim telemetry sendiri ke endpoint yang dikendalikan OpenAI, yaitu Datadog dan chatgpt.com, yang menurut panduan itu tidak berisi PII atau isi pesan. Jika review compliance Anda mensyaratkan halaman tanpa request ke pihak ketiga, ChatKit tidak cocok, bagaimanapun server-nya di-host.
Membandingkan dengan chatbot yang sudah saya jalankan membuat trade-off-nya konkret. Chatbot itu men-stream dari route berbasis Groq dan menyimpan semuanya di browser; ChatKit akan menggantikan sisi client dan memindahkan history ke server, dengan harga sebuah service Python dan dependency pada CDN.
| Aspek | ChatKit, self-hosted | Buatan sendiri, seperti chatbot situs ini |
|---|---|---|
| Streaming | StreamingResult lewat text/event-stream; client mem-parse event, termasuk progress tool | Loop getReader dan TextDecoder, plus penanganan abort, yang Anda tulis dan debug sendiri |
| History thread | Store di sisi server dengan daftar thread bawaan; Anda mengimplementasikan dua belas method | Apa pun yang Anda bangun; milik saya menyimpan 20 pesan terakhir di localStorage |
| Attachment | AttachmentStore dengan upload two_phase atau direct serta preview | Route upload, storage, preview, dan konversi ke model semuanya dibuat manual |
| Balasan kaya | Widget didesain di ChatKit Studio, dengan action di server atau client | Komponen sendiri dan protokol pesan sendiri untuk membawanya |
| Lokasi UI berjalan | Iframe dari CDN OpenAI, domain key dicek saat dimuat, ada telemetry OpenAI | Bundle Anda, CSP Anda, tanpa request ke pihak ketiga |
| Stack server | SDK server resmi adalah Python; respond bisa memanggil model atau agent apa pun | Bahasa dan provider apa pun; situs ini memakai route Next.js dan Groq |
Patokan saya dari tabel itu: ChatKit sepadan begitu Anda butuh dua dari tiga hal, yaitu thread di sisi server, attachment, dan widget, karena masing-masing adalah pekerjaan nyata yang mudah salah bila dibuat manual. Untuk asisten satu fungsi yang hanya men-stream teks, seperti yang ada di situs ini, UI buatan sendiri di atas stack Next.js yang sudah berjalan tetap lebih sederhana dan menjaga halaman bebas dari request pihak ketiga. Pertanyaan penentunya bukan soal UI chat, melainkan apakah sebuah service Python bisa diterima di deployment Anda.
ChatKit memindahkan bagian sulit dari chat agent, dari browser ke server, bukan menghilangkannya. UI, streaming, dan rendering widget sudah dikerjakan; autentikasi, Store yang di-scope per tenant, penyimpanan attachment, retensi, dan domain key belum. Bangun kelima hal itu dengan sengaja, muat history mulai dari yang terbaru, dan jadikan setiap action sebuah draft, maka jalur self-hosted memberi Anda chat agent yang datanya tetap milik Anda.
Sumber dan bacaan lanjutan