From 66460176f2c10249e6c0e2e97628b5e92f73289a Mon Sep 17 00:00:00 2001 From: Jiwon Kim Date: Mon, 1 Dec 2025 12:39:51 -0800 Subject: [PATCH 1/2] Docs update first pass - added guides section --- docs/actions.md | 264 --------- .../guides/add-features/accept-attachments.md | 149 +++++ .../add-features/create-custom-forms.md | 69 +++ .../add-features/handle-widget-actions.md | 129 +++++ .../guides/add-features/save-thread-titles.md | 49 ++ .../add-features/send-client-effects.md | 52 ++ docs/guides/add-features/stream-widgets.md | 108 ++++ .../add-features/use-client-tool-calls.md | 57 ++ docs/guides/convert-user-input.md | 85 +++ docs/guides/persist-chatkit-data.md | 113 ++++ docs/guides/run-inference.md | 67 +++ docs/guides/serve-chatkit.md | 94 +++ docs/guides/stream-thread-events.md | 157 +++++ docs/server.md | 524 ----------------- docs/widgets.md | 539 ------------------ mkdocs.yml | 21 +- 16 files changed, 1147 insertions(+), 1330 deletions(-) delete mode 100644 docs/actions.md create mode 100644 docs/guides/add-features/accept-attachments.md create mode 100644 docs/guides/add-features/create-custom-forms.md create mode 100644 docs/guides/add-features/handle-widget-actions.md create mode 100644 docs/guides/add-features/save-thread-titles.md create mode 100644 docs/guides/add-features/send-client-effects.md create mode 100644 docs/guides/add-features/stream-widgets.md create mode 100644 docs/guides/add-features/use-client-tool-calls.md create mode 100644 docs/guides/convert-user-input.md create mode 100644 docs/guides/persist-chatkit-data.md create mode 100644 docs/guides/run-inference.md create mode 100644 docs/guides/serve-chatkit.md create mode 100644 docs/guides/stream-thread-events.md delete mode 100644 docs/server.md delete mode 100644 docs/widgets.md diff --git a/docs/actions.md b/docs/actions.md deleted file mode 100644 index 4381dfe..0000000 --- a/docs/actions.md +++ /dev/null @@ -1,264 +0,0 @@ -# ChatKit actions - -Actions are a way for the ChatKit SDK frontend to trigger a streaming response without the user submitting a message. They can also be used to trigger side-effects outside ChatKit SDK. - -## Triggering actions - -### In response to user interaction with widgets - -Actions can be triggered by attaching an `ActionConfig` to any widget node that supports it. For example, you can respond to click events on Buttons. When a user clicks on this button, the action will be sent to your server where you can update the widget, run inference, stream new thread items, etc. - -```python -Button( - label="Example", - onClickAction=ActionConfig( - type="example", - payload={"id": 123}, - ) -) -``` - -Actions can also be sent imperatively by your frontend with `sendAction()`. This is probably most useful when you need ChatKit to respond to interaction happening outside ChatKit, but it can also be used to chain actions when you need to respond on both the client and the server (more on that below). - -```tsx -await chatKit.sendAction({ - type: "example", - payload: { id: 123 }, -}); -``` - -## Handling actions - -### On the server - -By default, actions are sent to your server. You can handle actions on your server by implementing the `action` method on `ChatKitServer`. - -```python -from collections.abc import AsyncIterator -from datetime import datetime -from typing import Any - -from chatkit.actions import Action -from chatkit.server import ChatKitServer -from chatkit.types import ( - HiddenContextItem, - ThreadItemDoneEvent, - ThreadMetadata, - ThreadStreamEvent, - WidgetItem, -) - -RequestContext = dict[str, Any] - - -class MyChatKitServer(ChatKitServer[RequestContext]): - async def action( - self, - thread: ThreadMetadata, - action: Action[str, Any], - sender: WidgetItem | None, - context: RequestContext, - ) -> AsyncIterator[ThreadStreamEvent]: - if action.type == "example": - await do_thing(action.payload['id']) - - # often you'll want to add a HiddenContextItem so the model - # can see that the user did something - hidden = HiddenContextItem( - id=self.store.generate_item_id("message", thread, context), - thread_id=thread.id, - created_at=datetime.now(), - content=["The user did a thing"], - ) - await self.store.add_thread_item(thread.id, hidden, context) - - # then you might want to run inference to stream a response - # back to the user. - async for e in self.generate(context, thread): - yield e - - if action.type == "another.example" - # ... -``` - -**NOTE:** As with any client/server interaction, actions and their payloads are sent by the client and should be treated as untrusted data. - -### Client - -Sometimes you’ll want to handle actions in your client integration. To do that you need to specify that the action should be sent to your client-side action handler by adding `handler="client` to the `ActionConfig`. - -```python -Button( - label="Example", - onClickAction=ActionConfig( - type="example", - payload={"id": 123}, - handler="client" - ) -) -``` - -Then, when the action is triggered, it will then be passed to a callback that you provide when instantiating ChatKit. - -```ts -async function handleWidgetAction(action: {type: string, Record}) { - if (action.type === "example") { - const res = await doSomething(action) - - // You can fire off actions to your server from here as well. - // e.g. if you want to stream new thread items or update a widget. - await chatKit.sendAction({ - type: "example_complete", - payload: res - }) - } -} - -chatKit.setOptions({ - // other options... - widgets: { onAction: handleWidgetAction } -}) -``` - -## Strongly typed actions - -By default `Action` and `ActionConfig` are not strongly typed. However, we do expose a `create` helper on `Action` making it easy to generate `ActionConfig`s from a set of strongly-typed actions. - -```python - -class ExamplePayload(BaseModel) - id: int - -ExampleAction = Action[Literal["example"], ExamplePayload] -OtherAction = Action[Literal["other"], None] - -AppAction = Annotated[ - ExampleAction - | OtherAction, - Field(discriminator="type"), -] - -ActionAdapter: TypeAdapter[AppAction] = TypeAdapter(AppAction) - -def parse_app_action(action: Action[str, Any]): AppAction - return ActionAdapter.validate_python(action) - -# Usage in a widget -# Action provides a create helper which makes it easy to generate -# ActionConfigs from strongly typed actions. -Button( - label="Example", - onClickAction=ExampleAction.create(ExamplePayload(id=123)) -) - -# usage in action handler -class MyChatKitServer(ChatKitServer[RequestContext]) - async def action( - self, - thread: ThreadMetadata, - action: Action[str, Any], - sender: WidgetItem | None, - context: RequestContext, - ) -> AsyncIterator[Event]: - # add custom error handling if needed - app_action = parse_app_action(action) - if (app_action.type == "example"): - await do_thing(app_action.payload.id) -``` - -## Use widgets and actions to create custom forms - -When widget nodes that take user input are mounted inside a `Form`, the values from those fields will be included in the `payload` of all actions that originate from within the `Form`. - -Form values are keyed in the `payload` by their `name` e.g. - -- `Select(name="title")` → `action.payload.title` -- `Select(name="todo.title")` → `action.payload.todo.title` - -```python -Form( - direction="col", - onSubmitAction=ActionConfig( - type="update_todo", - payload={"id": todo.id} - ), - children=[ - Title(value="Edit Todo"), - - Text(value="Title", color="secondary", size="sm"), - Text( - value=todo.title, - editable=EditableProps(name="title", required=True), - ) - - Text(value="Description", color="secondary", size="sm"), - Text( - value=todo.description, - editable=EditableProps(name="description"), - ), - - Button(label="Save", submit=true) - ] -) - -class MyChatKitServer(ChatKitServer[RequestContext]) - async def action( - self, - thread: ThreadMetadata, - action: Action[str, Any], - sender: WidgetItem | None, - context: RequestContext, - ) -> AsyncIterator[Event]: - if (action.type == "update_todo"): - id = action.payload['id'] - # Any action that originates from within the Form will - # include title and description - title = action.payload['title'] - description = action.payload['description'] - - # ... - -``` - -### Validation - -`Form` uses basic native form validation; enforcing `required` and `pattern` on fields where they are configured and blocking submission when the form has any invalid field. - -We may add new validation modes with better UX, more expressive validation, custom error display, etc in the future. Until then, widgets are not a great medium for complex forms with tricky validation. If you have this need, a better pattern would be to use client side action handling to trigger a modal, show a custom form there, then pass the result back into ChatKit with `sendAction`. - -### Treating `Card` as a `Form` - -You can pass `asForm=True` to `Card` and it will behave as a `Form`, running validation and passing collected fields to the Card’s `confirm` action. - -### Payload key collisions - -If there is a naming collision with some other existing pre-defined key on your payload, the form value will be ignored. This is probably a bug, so we’ll emit an `error` event when we see this. - -## Customize how actions interact with loading states in widgets - -Use `ActionConfig.loadingBehavior` to control how actions trigger different loading states in a widget. - -```python -Button( - label="This make take a while...", - onClickAction=ActionConfig( - type="long_running_action_that_should_block_other_ui_interactions", - loadingBehavior="container" - ) -) -``` - -| Value | Behavior | -| ----------- | ------------------------------------------------------------------------------------------------------------------------------- | -| `auto` | The action will adapt to how it’s being used. (_default_) | -| `self` | The action triggers loading state on the widget node that the action was bound to. | -| `container` | The action triggers loading state on the entire widget container. This causes the widget to fade out slightly and become inert. | -| `none` | No loading state | - -### Using `auto` behavior - -Generally, we recommend using `auto`, which is the default. `auto` triggers loading states based on where the action is bound, for example: - -- `Button.onClickAction` → `self` -- `Select.onChangeAction` → `none` -- `Card.confirm.action` → `container` diff --git a/docs/guides/add-features/accept-attachments.md b/docs/guides/add-features/accept-attachments.md new file mode 100644 index 0000000..f29776a --- /dev/null +++ b/docs/guides/add-features/accept-attachments.md @@ -0,0 +1,149 @@ +# Accept attachments + +Let users attach files/images by turning on client support, choosing an upload strategy, wiring the upload endpoints, and converting attachments to model inputs. + +## Enable attachments in the client + +Enable attachments in the composer and configure client-side limits: + +- Set `ChatKitOptions.composer.attachments.enabled = true` so the composer accepts file attachments. +- In the same `composer.attachments` block, configure accepted MIME types,per-message maximum attachment count, and per-file size limits: see [docs](https://openai.github.io/chatkit-js/api/openai/chatkit/type-aliases/composeroption/#attachments). + +## Configure an upload strategy + +Set [`ChatKitOptions.api.uploadStrategy`](https://openai.github.io/chatkit-js/api/openai/chatkit/type-aliases/fileuploadstrategy/) to: + +- **Direct**: your backend exposes a single upload URL that accepts the bytes and writes attachment metadata to your `Store`. Simpler and faster when you control uploads directly from the app server. +- **Two-phase**: the client makes a ChatKit API request to create an attachment metadata record (which forwards the request to `AttachmentStore`), you return an `upload_url` as part of the created attachment metadata, and the client uploads bytes in a second step. Prefer this when you front object storage with presigned/temporary URLs or want to offload upload bandwidth (e.g. to a third-party blob storage). + +Both strategies still require an `AttachmentStore` for delete cleanup. Choose direct for simplicity on the same origin; choose two-phase for cloud storage and larger files. + +## Enforce attachment access control + +Neither attachment metadata nor file bytes are protected by ChatKit. Use the `context` passed into your `AttachmentStore` methods to authorize every create/read/delete. Only return IDs, bytes, or signed URLs when the caller owns the attachment, and prefer short-lived download URLs. Skipping these checks can leak customer data. + +## If you chose direct upload + +Add the upload endpoint referenced in `uploadStrategy`. It must: + +- accept `multipart/form-data` with a `file` field, +- store the bytes wherever you like, +- create `Attachment` metadata, persist it via `Store.save_attachment`, and +- return the `Attachment` JSON. + +Implement `AttachmentStore.delete_attachment` to delete the stored bytes; `ChatKitServer` will then call `Store.delete_attachment` to drop metadata. + +Example client configuration: + +```js +{ + type: "direct", + uploadUrl: "/files", +} +``` + +Example FastAPI direct upload endpoint: + +```python +@app.post("/files") +async def upload_file(request: Request): + form_data = await request.form() + file = form_data.get("file") + + # Your blob store upload + attachment = await upload_to_blob_store(file) + + return Response(content=attachment.model_dump_json(), media_type="application/json") +``` + +## If you chose two-phase upload + +Implement `AttachmentStore.create_attachment` to: + +- build an `upload_url` that accepts `multipart/form-data` with a `file` field (direct PUTs are currently not supported), +- build the `Attachment` model, +- persist it via `Store.save_attachment`, and +- return it. + +Implement `AttachmentStore.delete_attachment` to delete the stored bytes; `ChatKitServer` will call `Store.delete_attachment` afterward. + +- The client POSTs the bytes to `upload_url` after it receives the created attachment metadata in the response. + +Client configuration: + +```js +{ + type: "two_phase", +} +``` + +Example two-phase store issuing a multipart upload URL: + +```python +attachment_store = BlobAttachmentStore() +server = MyChatKitServer(store=data_store, attachment_store=attachment_store) + +class BlobAttachmentStore(AttachmentStore[RequestContext]): + def generate_attachment_id(self, mime_type: str, context: RequestContext) -> str: + return f"att_{uuid4().hex}" + + async def create_attachment( + self, input: AttachmentCreateParams, context: RequestContext + ) -> Attachment: + att_id = self.generate_attachment_id(input.mime_type, context) + upload_url = issue_multipart_upload_url(att_id, input.mime_type) # your blob store + attachment = Attachment( + id=att_id, + mime_type=input.mime_type, + name=input.name, + upload_url=upload_url, + ) + await data_store.save_attachment(attachment, context=context) + return attachment + + async def delete_attachment(self, attachment_id: str, context: RequestContext) -> None: + await delete_blob(att_id=attachment_id) # your blob store +``` + +## Convert attachments to model input + +Attachments arrive on `input_user_message.attachments` in `ChatKitServer.respond`. The default `ThreadItemConverter` does not handle them, so subclass and implement `attachment_to_message_content` to return a `ResponseInputContentParam` before calling `Runner.run_streamed`. + +Example using a blob fetch helper: + +```python +from chatkit.agents import ThreadItemConverter +from chatkit.types import ImageAttachment +from openai.types.responses import ResponseInputFileParam, ResponseInputImageParam + +async def read_bytes(attachment_id: str) -> bytes: + ... # fetch from your blob store + +def as_data_url(mime: str, content: bytes) -> str: + return "data:" + mime + ";base64," + base64.b64encode(content).decode("utf-8") + +class MyConverter(ThreadItemConverter): + async def attachment_to_message_content(self, attachment): + content = await read_bytes(attachment.id) + if isinstance(attachment, ImageAttachment): + return ResponseInputImageParam( + type="input_image", + detail="auto", + image_url=as_data_url(attachment.mime_type, content), + ) + if attachment.mime_type == "application/pdf": + return ResponseInputFileParam( + type="input_file", + file_data=as_data_url(attachment.mime_type, content), + filename=attachment.name or "unknown", + ) + # For other text formats, check for API support first before + # sending as a ResponseInputFileParam. +``` + +## Show image attachment previews in thread + +Set `ImageAttachment.preview_url` to allow the client to render thumbnails. + +- If your preview URLs are **permanent/public**, set `preview_url` once when creating the attachment and persist it. +- If your storage uses **expiring URLs**, generate a fresh `preview_url` when returning attachment metadata (for example, in `Store.load_thread_items` and `Store.load_attachment`) rather than persisting a long-lived URL. In this case, returning a short-lived signed URL directly is the simplest approach. Alternatively, you may return a redirect that resolves to a temporary signed URL, as long as the final URL serves image bytes with appropriate CORS headers. diff --git a/docs/guides/add-features/create-custom-forms.md b/docs/guides/add-features/create-custom-forms.md new file mode 100644 index 0000000..5bc2222 --- /dev/null +++ b/docs/guides/add-features/create-custom-forms.md @@ -0,0 +1,69 @@ +# Create custom forms + +Wrap widgets that collect user input in a `Form` to have their values automatically injected into every action triggered inside that form. The form values arrive in the action payload, keyed by each field’s `name`. + +- `` → `action.payload["todo"]["title"]` + +```jsx +
+ + <Text value="Title" color="secondary" size="sm" /> + <Text + value={todo.title} + editable={{ name: "title", required: true }} + /> + <Text value="Description" color="secondary" size="sm" /> + <Text + value={todo.description} + editable={{ name: "description" }} + /> + <Button label="Save" submit /> +</Form> +``` + +On the server, read the form values from the action payload. Any action originating from inside the form will include the latest field values. + +```python +from collections.abc import AsyncIterator +from chatkit.server import ChatKitServer +from chatkit.types import Action, ThreadMetadata, ThreadStreamEvent, WidgetItem + + +class MyChatKitServer(ChatKitServer[RequestContext]): + async def action( + self, + thread: ThreadMetadata, + action: Action[str, Any], + sender: WidgetItem | None, + context: RequestContext, + ) -> AsyncIterator[ThreadStreamEvent]: + if action.type == "update_todo": + todo_id = action.payload["id"] + # Any action that originates from within the Form will + # include title and description + title = action.payload["title"] + description = action.payload["description"] + + # ... +``` + +### Validation + +`Form` uses basic native form validation; it enforces `required` and `pattern` on configured fields and blocks submission when any field is invalid. + +We may add new validation modes with better UX, more expressive validation, and custom error display. Until then, widgets are not a great medium for complex forms with tricky validation. If you need this, a better pattern is to use client-side action handling to trigger a modal, show a custom form there, then pass the result back into ChatKit with `sendCustomAction`. + +### Treating `Card` as a `Form` + +You can pass `asForm=True` to `Card` and it will behave as a `Form`, running validation and passing collected fields to the Card’s `confirm` action. + +### Payload key collisions + +If there is a naming collision with some other existing pre-defined key on your payload, the form value will be ignored. This is probably a bug, so we’ll emit an `error` event when we see this. diff --git a/docs/guides/add-features/handle-widget-actions.md b/docs/guides/add-features/handle-widget-actions.md new file mode 100644 index 0000000..c17abc0 --- /dev/null +++ b/docs/guides/add-features/handle-widget-actions.md @@ -0,0 +1,129 @@ +# Handle widget actions + +Actions let widget interactions trigger server or client logic without posting a chat message. + +## Define actions in your widget definition + +Configure actions as part of the widget definition while you design it in <https://widgets.chatkit.studio>. Add an action to any action-capable component such as `Button.onClickAction`; explore supported components [here](https://widgets.chatkit.studio/components). The exported `.widget` file already includes the action object, so loading the template is enough for ChatKit to send it. + +```jsx +<Button + label="Send message" + onClickAction={{ + type: "send_message", + payload: { text: "Ping support" }, + }} +/> +``` + +## Choose client vs server handling + +Actions are handled on the server by default and flow into `ChatKitServer.action`. Set `handler: "client"` in the action to route it to your frontend’s `widgets.onAction` instead. Use the server when you need to update thread state or stream widgets; use the client for immediate UI work or to chain into a follow-up `sendCustomAction` after local logic completes. + +Example widget definition with a client action handler: + +```jsx +<Button + label="Send message" + onClickAction={{ + type: "send_message", + handler: "client", + payload: { text: "Ping support" }, + }} +/> +``` + +## Handle actions on the server + +Implement `ChatKitServer.action` to process incoming actions. The `sender` argument is the widget item that triggered the action (if available). + +```python +from datetime import datetime + +from chatkit.server import ChatKitServer, stream_widget +from chatkit.types import HiddenContextItem, WidgetItem + +class MyChatKitServer(ChatKitServer[RequestContext]): + async def action(self, thread, action, sender, context): + if action.type == "send_message": + await send_to_chat(action.payload["text"]) + + # Record the user action so the model can see it on the next turn. + hidden = HiddenContextItem( + id="generated-item-id", + thread_id=thread.id, + created_at=datetime.now(), + content=f"User sent message: {action.payload['text']}", + ) + # HiddenContextItems need to be manually saved because ChatKitServer + # only auto-saves streamed items, and HiddenContextItem should never be streamed to the client. + await self.store.add_thread_item(thread.id, hidden, context) + + # Stream an updated widget back to the client. + updated_widget = build_message_widget(text=action.payload["text"]) + async for event in stream_widget( + thread, + updated_widget, + generate_id=lambda item_type: self.store.generate_item_id(item_type, thread, context), + ): + yield event +``` + +Treat action payloads as untrusted input from the client. + +## Handle actions on the client + +Provide [`widgets.onAction`](https://openai.github.io/chatkit-js/api/openai/chatkit/type-aliases/widgetsoption) when creating ChatKit on the client; you can still forward follow-up actions to the server from your `onAction` callback with the `sendCustomAction()` command if needed. + +```ts +const chatkit = useChatKit({ + // ... + widgets: { + onAction: async (action, widgetItem) => { + if (action.type === "save_profile") { + const result = await saveProfile(action.payload); + + // Optionally invoke a server action after client-side work completes. + await chatkit.sendCustomAction( + { + type: "save_profile_complete", + payload: { ...result, user_id: action.payload.user_id }, + }, + widgetItem.id, + ); + } + }, + }, +}); +``` + +On the server, handle the follow-up action (`save_profile_complete`) in the `action` method to stream refreshed widgets or messages. + +## Customize how actions interact with loading states in widgets + +Use `loadingBehavior` to control how actions trigger different loading states in a widget. + +```jsx +<Button + label="Send message" + onClickAction={{ + type: "send_message", + loadingBehavior: "container", + }} +/> +``` + +| Value | Behavior | +| ----------- | ------------------------------------------------------------------------------------------------------------------------------- | +| `auto` | The action will adapt to how it’s being used. (_default_) | +| `self` | The action triggers loading state on the widget node that the action was bound to. | +| `container` | The action triggers loading state on the entire widget container. This causes the widget to fade out slightly and become inert. | +| `none` | No loading state | + +### Using `auto` behavior + +Generally, we recommend using `auto`, which is the default. `auto` triggers loading states based on where the action is bound, for example: + +- `Button.onClickAction` → `self` +- `Select.onChangeAction` → `none` +- `Card.confirm.action` → `container` diff --git a/docs/guides/add-features/save-thread-titles.md b/docs/guides/add-features/save-thread-titles.md new file mode 100644 index 0000000..2c190bc --- /dev/null +++ b/docs/guides/add-features/save-thread-titles.md @@ -0,0 +1,49 @@ +# Save thread titles + +Threads start untitled. Give them short titles so inboxes and client lists stay readable. + +## Save a title + +Update `thread.title` and call `store.save_thread(thread, context=...)`. Do this inside your streaming pipeline so ChatKit can emit the resulting `ThreadUpdatedEvent` to connected clients. + +```python +class MyChatKitServer(ChatKitServer[RequestContext]): + async def respond(...): + ... + if not thread.title: + thread.title = "My Thread Title" + await self.store.save_thread(thread, context=context) +``` + +If your integration writes titles elsewhere (for example, a separate FastAPI route that calls `store.save_thread` directly), have the client call `chatkit.fetchUpdates()` command afterward to pull the latest thread metadata. + + +## Auto-generate a title + +Generate a concise title after the first assistant turn once you have enough context. Skip if the thread already has a title or if there isn’t enough content to summarize. + +```python +class MyChatKitServer(ChatKitServer[RequestContext]): + async def respond(...): + updating_thread_title = asyncio.create_task( + self._maybe_update_thread_title(thread, item, context) + ) + + # Stream your main response + async for event in stream_agent_response(agent_context, result): + yield event + + # Await so the title update streams back as a ThreadUpdatedEvent + await updating_thread_title + + async def _maybe_update_thread_title(self, thread: ThreadMetadata, context: RequestContext): + if thread.title is not None: + return + items = await self.store.load_thread_items( + thread.id, after=None, limit=6, order="desc", context=context + ) + thread.title = await generate_short_title(items.data) # your model call + await self.store.save_thread(thread, context=context) +``` + +Use any model call you like for `generate_short_title`: run a tiny Agent, a simple completion, or your own heuristic. Keep titles brief (for example, 3–6 words). diff --git a/docs/guides/add-features/send-client-effects.md b/docs/guides/add-features/send-client-effects.md new file mode 100644 index 0000000..b014154 --- /dev/null +++ b/docs/guides/add-features/send-client-effects.md @@ -0,0 +1,52 @@ +# Send client effects + +Send ClientEffectEvent to trigger fire-and-forget UI work (such as refreshing a view, opening a modal, showing a toast) without creating thread items or pausing the model stream. + +!!! note "Client effects vs. client tool calls" + Client effects are ephemeral: they stream immediately to ChatKit.js, trigger your registered effect handler, and are not persisted to the thread history. Use client tool calls instead when you need a round-trip response from the client. + +## Stream a client effect from your server + +Yield client effects directly from the `respond` or `action` method: + +```python +async def respond(...): + yield ClientEffectEvent( + name="highlight_text", + data={"index": 142, "length": 35}, + ) +``` + +Or from tools, through `AgentContext`: + +```python +from agents import RunContextWrapper, function_tool +from chatkit.agents import AgentContext +from chatkit.types import ClientEffectEvent + +@function_tool() +async def highlight_text(ctx: RunContextWrapper[AgentContext], index: int, length: int): + await ctx.context.stream( + ClientEffectEvent( + name="highlight_text", + data={"index": index, "length": length}, + ) + ) +``` + +## Handle the client effect in ChatKit.js + +Register a client effect handler when initializing ChatKit on the client. + +```ts +const chatkit = useChatKit({ + // ... + onEffect: async ({name, data}) => { + if (name === "highlight_text") { + const {index, length} = data; + const nodes = highlightArticleText({index, length}); + // No return value needed + }, + }, +}); +``` diff --git a/docs/guides/add-features/stream-widgets.md b/docs/guides/add-features/stream-widgets.md new file mode 100644 index 0000000..ce0e270 --- /dev/null +++ b/docs/guides/add-features/stream-widgets.md @@ -0,0 +1,108 @@ +# Stream widgets + +Widgets are rich UI components that can be displayed in chat. Design .widget files in <https://widgets.chatkit.studio>, load them as WidgetTemplates, inject dynamic data to build widgets, and stream the rendered widgets as `WidgetItem`s to clients from the `respond` or `action` ChatKitServer methods or from tool calls. + +## Design widgets + +Use <https://widgets.chatkit.studio> to visually design cards, lists, forms, charts, and other widget components. Populate the **Data** panel with sample values to preview how the widget renders with real inputs. + +When the layout and bindings look correct, click **Export** to download the generated `.widget` file. Commit this file alongside the server code that builds and renders the widget. + +For wiring user interactions and handling widget actions on the server, see [Handle widget actions](handle-widget-actions.md). + +## Build widgets with `WidgetTemplate` + +Load the `.widget` file with `WidgetTemplate.from_file` and hydrate it with runtime data. Placeholders inside the `.widget` template (Jinja-style `{{ }}` expressions) are rendered before the widget is streamed. + +```python +from chatkit.widgets import WidgetTemplate + +message_template = WidgetTemplate.from_file("widgets/channel_message.widget") + +def build_message_widget(user_name: str, message: str): + # Replace this helper with whatever your integration uses to build widgets. + return message_template.build( + { + "user_name": user_name, + "message": message, + } + ) +``` + +`WidgetTemplate.build` accepts plain dicts or Pydantic models. Use `.build_basic` if you're working with a `BasicRoot` widget outside of streaming. + +## Stream widgets from `respond` + +Use `stream_widget` to emit a one-off widget or stream updates from an async generator. + +```python +from chatkit.server import stream_widget + +async def respond(...): + user_name = "Harry Potter" + message = "Yer a wizard, Harry" + message_widget = build_message_widget(user_name=user_name, message=message) + async for event in stream_widget( + thread, + message_widget, + copy_text=f"Message to {user_name}: {message}", + generate_id=lambda item_type: self.store.generate_item_id(item_type, thread, context), + ): + yield event +``` + +To stream gradual updates, yield successive widget states from an async generator; `stream_widget` diffs and emits `ThreadItemUpdatedEvent`s for you. + +## Stream widgets from tools + +Tools can enqueue widgets via `AgentContext.stream_widget`; `stream_agent_response` forwards them to the client. + +```python +from agents import RunContextWrapper, function_tool +from chatkit.agents import AgentContext + +@function_tool(description_override="Display a sample widget to the user.") +async def sample_widget(ctx: RunContextWrapper[AgentContext]): + message_widget = build_message_widget(...) + await ctx.context.stream_widget(message_widget) +``` + +## Stream widget updates + +The examples above return a fully completed static widget. You can also stream an updating widget by yielding new versions of the widget from a generator function. The ChatKit framework will send updates for the parts of the widget that have changed. + +!!! note "Text streaming support" + Currently, only `<Text>` and `<Markdown>` components marked with an `id` have their text updates streamed. Other diffs will forgo the streaming UI and replace and rerender parts of the widget client-side. + +```python +from typing import AsyncGenerator + +from agents import RunContextWrapper, function_tool +from chatkit.agents import AgentContext, Runner +from chatkit.widgets import WidgetRoot + +@function_tool +async def draft_message_to_harry(ctx: RunContextWrapper[AgentContext]): + # message_generator is your model/tool function that streams text + message_result = Runner.run_streamed(message_generator, "Draft a message to Harry.") + + async def widget_generator() -> AsyncGenerator[WidgetRoot, None]: + message = "" + async for event in message_result.stream_events(): + if event.type == "raw_response_event" and event.data.type == "response.output_text.delta": + message += event.data.delta + yield build_message_widget( + user_name="Harry Potter", + message=message, + ) + + # Final render after streaming completes. + yield build_message_widget( + user_name="Harry Potter", + message=message, + ) + + await ctx.context.stream_widget(widget_generator()) +``` + +The inner generator collects the streamed text events and rebuilds the widget with the latest message so the UI updates incrementally. diff --git a/docs/guides/add-features/use-client-tool-calls.md b/docs/guides/add-features/use-client-tool-calls.md new file mode 100644 index 0000000..b02de45 --- /dev/null +++ b/docs/guides/add-features/use-client-tool-calls.md @@ -0,0 +1,57 @@ +# Use client tool calls + +Client tool calls let the agent invoke browser/app callbacks mid-inference. Register the tool on both client and server; when triggered, ChatKit pauses the model, sends the tool request to the client, and resumes with the returned result. + +!!! note "Prefer client effects for non-blocking updates" + Use client effects instead when you do not need to wait for the client callback response for the rest of your response. See [Send client effects](send-client-effects.md) for more details. + +## Define a client tool in your agent + +Set `ctx.context.client_tool_call` inside a tool and configure the agent to stop at that tool. + +Only one client tool call can run per turn. Include client tools in `stop_at_tool_names` so the model pauses while the client callback runs and returns its result. + + +```python +from agents import Agent, RunContextWrapper, StopAtTools, function_tool +from chatkit.agents import AgentContext, ClientToolCall + +@function_tool(description_override="Read the user's current canvas selection.") +async def get_selected_canvas_nodes(ctx: RunContextWrapper[AgentContext]) -> None: + ctx.context.client_tool_call = ClientToolCall( + name="get_selected_canvas_nodes", + arguments={"project": my_project()}, + ) + +assistant = Agent[AgentContext]( + ... + tools=[get_selected_canvas_nodes], + # StopAtTools pauses model generation so the pending client callback can run and resume the run. + tool_use_behavior=StopAtTools(stop_at_tool_names=[get_selected_canvas_nodes.name]), +) +``` + +## Register the client tool in ChatKit.js + +Provide a matching callback when initializing ChatKit on the client. The function name must match the `ClientToolCall.name`, and its return value is sent back to the server to resume the run. + +```ts +const chatkit = useChatKit({ + // ... + onClientTool: async ({name, params}) => { + if (name === "get_selected_canvas_nodes") { + const {project} = params; + const nodes = myCanvas.getSelectedNodes(project); + return { + nodes: nodes.map((node) => ({ id: node.id, kind: node.type })), + }; + }, + }, +}); +``` + +## Stream and resume + +In `respond`, stream via `stream_agent_response` as usual. ChatKit emits a pending client tool call item; the frontend runs your registered client tool, posts the output back, and the server continues the run. + +When the client posts the tool result, ChatKit stores it as a `ClientToolCallItem`. The continued inference after the client tool call handler returns the result feeds both the call and its output back to the model through `ThreadItemConverter.client_tool_call_to_input`, which emits a `function_call` plus matching `function_call_output` so the model sees the browser-provided context. diff --git a/docs/guides/convert-user-input.md b/docs/guides/convert-user-input.md new file mode 100644 index 0000000..52864a4 --- /dev/null +++ b/docs/guides/convert-user-input.md @@ -0,0 +1,85 @@ +# Convert user input to model input + +ChatKit delivers structured thread items (messages, tools, attachments). Before running inference, convert those items into the model's expected input format. + +## Load recent thread items + +Make the agent aware of recent context before converting input. Load recent thread items and pass them along with the new message so the model sees the conversation state. + +```python +# Inside ChatKitServer.respond(...) +items_page = await self.store.load_thread_items( + thread.id, + after=None, + limit=20, + order="desc", + context=context, +) +items = list(reversed(items_page.data)) +``` + +## Use default conversion helpers + +Start with the defaults: `simple_to_agent_input` converts a `UserMessageItem` into Agents SDK inputs, and `ThreadItemConverter` lets you override specific conversions when you need more control. Combine the converted user input with the `items` you loaded above to send the model both the latest message and recent thread context. + +```python +from agents import Agent, Runner +from chatkit.agents import AgentContext, simple_to_agent_input, stream_agent_response + + +async def respond( + self, + thread: ThreadMetadata, + input: UserMessageItem | None, + context: Any, +) -> AsyncIterator[ThreadStreamEvent]: + # Assume `items` was loaded as shown in the previous section. + + input_items = await simple_to_agent_input(items) + agent_context = AgentContext(thread=thread, store=self.store, request_context=context) + result = Runner.run_streamed( + assistant_agent, + input_items, + context=agent_context, + ) +``` + +See [Stream thread events](stream-thread-events.md) for how to stream the resulting events back to the client. + +## Customize a ThreadItemConverter + +Extend `ThreadItemConverter` when the defaults do not match your agent instructions (e.g. your prompt expects special tags around hidden context or tasks) or when you persist items the simple converter does not cover, such as @-mentions (entity tagging) or attachments. The example below wraps hidden context in a dedicated system message so the model treats it as internal-only guidance. + +```python +class MyConverter(ThreadItemConverter): + async def hidden_context_to_input( + self, item: HiddenContextItem + ) -> Message: + text = ( + "DO NOT SHOW TO USER. Internal context for the assistant:\n" + f"<context>\n{item.content}\n</context>" + ) + return Message( + type="message", + role="system", + content=[ + ResponseInputTextParam( + type="input_text", + text=text, + ) + ], + ) +``` + +You can also override methods like `attachment_to_message_content` or `tag_to_message_content` to translate @-mentions or attachments into model-readable text. + +## Interpret inference options + +When you have specified composer options for tools or models in ChatKit.js, user-selected model or tool settings arrive as `input.inference_options`. Pass them through to your model runner—or even switch which agent you invoke—so the experience follows the user's choices. + +```python +if input and input.inference_options: + model = input.inference_options.model + tool_choice = input.inference_options.tool_choice + # forward these into your inference call +``` diff --git a/docs/guides/persist-chatkit-data.md b/docs/guides/persist-chatkit-data.md new file mode 100644 index 0000000..00e9317 --- /dev/null +++ b/docs/guides/persist-chatkit-data.md @@ -0,0 +1,113 @@ +# Persist ChatKit threads and messages + +Implement the `Store` interface to control how threads, messages, tool calls, and widgets are stored. Prefer serializing thread items as JSON so schema changes in future releases do not break your storage. + +## Implement a Store + +Example `Store` backed by Postgres and `psycopg`: + +```python +class MyPostgresStore(Store[RequestContext]): + """Chat data store backed by Postgres.""" + + def __init__(self, conninfo: str) -> None: + self._conninfo = conninfo + self._init_schema() + + @contextmanager + def _connection(self) -> Iterator[psycopg.Connection]: + # Uses blocking psycopg for simplicity. + # In production async servers, consider an async driver or connection pool. + with psycopg.connect(self._conninfo) as conn: + yield conn + + def _init_schema(self) -> None: + with self._connection() as conn, conn.cursor() as cur: + # Threads are typically queried by (user_id, created_at), + # so you may want to add an index on those columns in production. + cur.execute( + """ + CREATE TABLE IF NOT EXISTS threads ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL, + data JSONB NOT NULL + ); + """ + ) + + # Items are typically streamed by (thread_id, created_at) and + # sometimes filtered by user_id, so add indexes accordingly in production. + cur.execute( + """ + CREATE TABLE IF NOT EXISTS items ( + id TEXT PRIMARY KEY, + thread_id TEXT NOT NULL + REFERENCES threads (id) + ON DELETE CASCADE, + user_id TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL, + data JSONB NOT NULL + ); + """ + ) + + conn.commit() + + async def load_thread( + self, thread_id: str, context: RequestContext + ) -> ThreadMetadata: + with self._connection() as conn, conn.cursor(row_factory=tuple_row) as cur: + cur.execute( + "SELECT data FROM threads WHERE id = %s AND user_id = %s", + (thread_id, context.user_id), + ) + row = cur.fetchone() + if row is None: + raise NotFoundError(f"Thread {thread_id} not found") + + return ThreadMetadata.model_validate(row[0]) + + async def save_thread( + self, thread: ThreadMetadata, context: RequestContext + ) -> None: + payload = thread.model_dump(mode="json") + + with self._connection() as conn, conn.cursor() as cur: + cur.execute( + """ + INSERT INTO threads (id, user_id, created_at, data) + VALUES (%s, %s, %s, %s) + """, + (thread.id, context.user_id, thread.created_at, payload), + ) + conn.commit() + + # Remaining Store methods follow the same pattern +``` + +See the [`Store` interface](../../api/chatkit/store/#chatkit.store.Store) for the full list of required methods. + +### Customize ID generation + +If you need custom thread or item IDs you can override the store's ID generation methods `generate_thread_id` and `generate_item_id`. + +This is useful when integrating with an external ID system, enforcing a specific ID format, or requiring deterministic or cross-service–unique IDs. + +For most applications, the default implementations are sufficient. + +### Store thread metadata + +`ThreadMetadata` can hold arbitrary, non-UI data needed for your application such as the last `previous_response_id` or customer identifiers. + +```python +previous_response_id = thread.metadata.get("previous_response_id") + +result = Runner.run_streamed( + agent, + input=..., + previous_response_id=previous_response_id, +) + +thread.metadata["previous_response_id"] = result.response_id +``` diff --git a/docs/guides/run-inference.md b/docs/guides/run-inference.md new file mode 100644 index 0000000..2c07c4e --- /dev/null +++ b/docs/guides/run-inference.md @@ -0,0 +1,67 @@ +# Run inference + +The Agents SDK is the officially supported way to run inference with ChatKit and stream results back, but it is not mandatory. Any pipeline that yields `ThreadStreamEvent`s will work. + +If you are not using Agents SDK, emit `ThreadStreamEvent`s yourself from `respond`. Assistant messages, tool status, notices, and widgets are all first-class events; see [Stream thread events](stream-thread-events.md) for patterns. + +## Access ChatKit helpers inside tools + +`AgentContext` is passed through to server tool calls (via `RunContextWrapper`) so tools can stream events or use the store. For example, use `ctx.context.stream(...)` to update the UI while a tool runs (more details in [Stream thread events](stream-thread-events.md)), or `ctx.context.store` to load or persist thread data during tool execution. + +Attach server tools to your agent as usual; each tool receives the same `AgentContext` you constructed before running inference, giving it access to the current thread, store, and request context. + +You can subclass `AgentContext` to add app-specific context that tools and agents can use directly, such as a separate data store. + +```python +class MyAgentContext(AgentContext[RequestContext]): + data_store: MyDataStore + analytics: AnalyticsClient + + +async def respond(...): + agent_context = MyAgentContext( + thread=thread, + store=self.store, # your ChatKit data store + request_context=context, # your ChatKit request context (headers, auth) + data_store=self.data_store, # example addition: app-specific store + analytics=self.analytics, # example addition: app-specific service + ) + result = Runner.run_streamed( + assistant_agent, + input_items, + context=agent_context, + ) +``` + +Tools now receive `ctx.context` typed as `MyAgentContext`, so they can read or write app state without extra plumbing. + +## Client tool calls + +Client tool calls mirror server tool calls, except they seamlessly invoke a ChatKit.js client callback you registered on the frontend while inference runs. Trigger one by setting `ctx.context.client_tool_call` inside a tool and registering the same tool on both client and server. + +Only one client tool call is allowed per turn, and the agent must stop at the tool before continuing. See also [Use client tool calls](add-features/use-client-tool-calls.md). + +```python +@function_tool(description_override="Add an item to the user's todo list.") +async def add_to_todo_list(ctx: RunContextWrapper[AgentContext], item: str) -> None: + ctx.context.client_tool_call = ClientToolCall( + name="add_to_todo_list", + arguments={"item": item}, + ) + +assistant_agent = Agent[AgentContext]( + model="gpt-5", + name="Assistant", + instructions="You are a helpful assistant", + tools=[add_to_todo_list], + tool_use_behavior=StopAtTools(stop_at_tool_names=[add_to_todo_list.name]), +) +``` + +## Send agent reference content + +You can supply additional reference context to the model at inference time using server tools, client tools, or manual input injection. Choose the mechanism based on where the data lives and who owns it. + +- Use server tools when the reference content lives on the backend and can be retrieved during inference. +- Use client tool calls when the browser or app must supply transient state (for example, active UI selections). +- Manually inject additional model input items when the reference content is already available at inference time and your application is latency-sensitive. diff --git a/docs/guides/serve-chatkit.md b/docs/guides/serve-chatkit.md new file mode 100644 index 0000000..8a88ffc --- /dev/null +++ b/docs/guides/serve-chatkit.md @@ -0,0 +1,94 @@ +# Serve ChatKit from your backend + +ChatKit's server integration is intentionally small: implement a `ChatKitServer`, wire up a single POST endpoint, and stream `ThreadStreamEvent`s back to the client. You decide where to run the server and how to authenticate requests. + +## Install the SDK + +Install the `openai-chatkit` package: + +```bash +pip install openai-chatkit +``` + +## Implement a ChatKit server + +Subclass `ChatKitServer` and implement `respond`. This method runs every time a user sends a message and should stream back the events that make up your response (assistant messages, tool calls, workflows, tasks, widgets, and so on). + +```python +from collections.abc import AsyncIterator +from dataclasses import dataclass +from datetime import datetime + +from chatkit.server import ChatKitServer +from chatkit.types import ( + AssistantMessageContent, + AssistantMessageItem, + ThreadItemDoneEvent, + ThreadMetadata, + ThreadStreamEvent, + UserMessageItem, +) + + +@dataclass +class MyRequestContext: + user_id: str + + +class MyChatKitServer(ChatKitServer[MyRequestContext]): + async def respond( + self, + thread: ThreadMetadata, + input: UserMessageItem | None, + context: MyRequestContext, + ) -> AsyncIterator[ThreadStreamEvent]: + # Replace this with your inference pipeline. + yield ThreadItemDoneEvent( + item=AssistantMessageItem( + thread_id=thread.id, + id=self.store.generate_item_id("message", thread, context), + created_at=datetime.now(), + content=[AssistantMessageContent(text="Hi there!")], + ) + ) +``` + +## Pass request context into ChatKit + +`ChatKitServer[TContext]` and `Store[TContext]` are generic over a request context type you choose. Your context carries caller-specific data (for example user id, org, auth scopes, feature flags) into `ChatKitServer.respond` and your `Store`. Define a lightweight type and pass it through when you call `server.process`. + +```python +context = MyRequestContext(user_id="abc123") +result = await server.process(await request.body(), context) +``` + +## Expose the ChatKit endpoint + +ChatKit is framework-agnostic. Expose a single POST endpoint that returns JSON or streams server‑sent events (SSE). + +Example using ChatKit with FastAPI: + +```python +from fastapi import FastAPI, Request, Response +from fastapi.responses import StreamingResponse +from chatkit.server import ChatKitServer, StreamingResult + +app = FastAPI() +data_store = MyPostgresStore(conn_info) +server = MyChatKitServer(data_store) + + +@app.post("/chatkit") +async def chatkit_endpoint(request: Request): + context = MyRequestContext(...) + result = await server.process(await request.body(), context) + if isinstance(result, StreamingResult): + return StreamingResponse(result, media_type="text/event-stream") + return Response(content=result.json, media_type="application/json") +``` + +### (Optional) Pass through request metadata + +Every ChatKit request payload includes a `metadata` field you can use to carry per-request context from the client. + +Pull it from the request in your endpoint before calling server.process to use it for auth/tracing/business logic there, or to include it in the context you pass through so respond and tools can read it. diff --git a/docs/guides/stream-thread-events.md b/docs/guides/stream-thread-events.md new file mode 100644 index 0000000..8244fdb --- /dev/null +++ b/docs/guides/stream-thread-events.md @@ -0,0 +1,157 @@ +# Stream responses back to your user + +ChatKit.js listens for `ThreadStreamEvent`s over SSE. Stream events from `ChatKitServer.respond` so users see model output, tool activity, progress updates, and errors in real time. + +Thread events include both persistent thread items (messages, tools, workflows) that are saved to the conversation history, and non-persistent runtime signals (progress updates, notices, errors, and client effects) that show ephemeral UI or drive immediate client behavior without being stored. + +### From `respond` + +`stream_agent_response` converts a streamed Agents SDK run into ChatKit events. Yield those events directly from `respond`, or yield any `ThreadStreamEvent` yourself—the server processes them the same way. + +Example using `stream_agent_response` with a run result: + +```python +class MyChatKitServer(ChatKitServer[MyRequestContext]): + async def respond(...) -> AsyncIterator[ThreadStreamEvent]: + # Build model inputs and agent context as shown in previous guides. + + result = Runner.run_streamed(...) + async for event in stream_agent_response(agent_context, result): + yield event +``` + +### From tools + +Server tools enqueue events with the `AgentContext` helpers; `stream_agent_response` drains and forwards them. + +Example emitting an ephemeral progress update event during a tool call: + +```python +@function_tool() +async def long_running_tool(ctx: RunContextWrapper[AgentContext]): + await ctx.context.stream(ProgressUpdateEvent(text="Working...")) + + # Tool logic omitted for brevity +``` + +Example streaming workflow updates using `AgentContext` helpers: + +```python +@function_tool() +async def long_running_tool_with_steps(ctx: RunContextWrapper[AgentContext]): + # Create an empty workflow container + await ctx.context.start_workflow(Workflow(type="custom", tasks=[])) + + # Add and update the first task + discovery = CustomTask(title="Search data sources", status_indicator="loading") + await ctx.context.add_workflow_task(discovery) + + # Run the first task + await search_my_data_sources() + + await ctx.context.update_workflow_task( + discovery.model_copy(update={"status_indicator": "complete"}), task_index=0 + ) + + # Add a follow-up task + summary = CustomTask(title="Summarize findings", status_indicator="loading") + await ctx.context.add_workflow_task(summary) + + # Run the second task + await summarize_my_findings() + + await ctx.context.update_workflow_task( + summary.model_copy(update={"status_indicator": "complete"}), task_index=1 + ) + + # Close the workflow and collapse it in the UI + await ctx.context.end_workflow( + summary=CustomSummary(title="Analysis complete"), + expanded=False, + ) +``` + +### Handle guardrail triggers + +Guardrail tripwires raise `InputGuardrailTripwireTriggered` or `OutputGuardrailTripwireTriggered` once partial assistant output has been rolled back. Catch them around `stream_agent_response` and optionally send a user-facing event so the client knows why the turn stopped. + +```python +from agents import InputGuardrailTripwireTriggered, OutputGuardrailTripwireTriggered +from chatkit.types import ErrorEvent + +try: + async for event in stream_agent_response(agent_context, result): + yield event +except InputGuardrailTripwireTriggered: + yield ErrorEvent(message="We blocked that message for safety.") +except OutputGuardrailTripwireTriggered: + yield ErrorEvent( + message="The assistant response was blocked.", + allow_retry=False, + ) +``` + +## Stream events without `stream_agent_response` + +You can bypass the Agents SDK helper and yield `ThreadStreamEvent`s directly from `respond`. ChatKitServer will persist and route them the same way. + +```python +class MyChatKitServer(ChatKitServer[MyRequestContext]): + async def respond(...) -> AsyncIterator[ThreadStreamEvent]: + # Example transient progress update + yield ProgressUpdateEvent( + icon="search", + text="Searching..." + ) + + # Run your inference pipeline here + output = await run_inference(thread, input, context) + + # Stream a persisted assistant message + yield ThreadItemDoneEvent( + item=AssistantMessageItem( + thread_id=thread.id, + id=self.store.generate_item_id("message", thread, context), + created_at=datetime.now(), + content=[AssistantMessageContent(text=output)], + ) + ) +``` + +When you stream thread events manually, remember that tools cannot `yield` events. If you skip `stream_agent_response`, you must merge any tool-emitted events yourself—for example, by reading from `AgentContext._events` (populated by `ctx.context.stream(...)` or workflow helpers) and interleaving them with your own `respond` events. + +## Thread lifecycle events + +Thread item events drive the conversation state. ChatKitServer processes these events to persist conversation state before streaming them back to the client. + +- `ThreadItemAddedEvent`: introduce a new item (message, tool call, workflow, widget, etc). +- `ThreadItemUpdatedEvent`: mutate a pending item (e.g., stream text deltas, workflow task updates). +- `ThreadItemDoneEvent`: mark an item complete and persist it. +- `ThreadItemRemovedEvent`: delete an item by id. +- `ThreadItemReplacedEvent`: swap an item in place. + +Note: `ThreadItemAddedEvent` does not persist the item. `ChatKitServer` saves on `ThreadItemDoneEvent`/`ThreadItemReplacedEvent`, tracks pending items in between, and handles store writes for all `ThreadItem*Event`s. + +## Errors + +Stream an `ErrorEvent` for user-facing errors. + +```python +async def respond(...) -> AsyncIterator[ThreadStreamEvent]: + if not user_has_remaining_quota(context): + yield ErrorEvent( + message="You have reached your usage limit.", + allow_retry=False, + ) + return + + # Rest of your respond method +``` + +## Client effects + +Use `ClientEffectEvent` to trigger fire-and-forget behavior on the client such as opening a dialog or pushing updates. + +## Stream options + +`StreamOptionsEvent` configures runtime stream behavior (for example, allowing user cancellation). `ChatKitServer` emits one at the start of every stream using `get_stream_options`; override that method to change defaults such as `allow_cancel`. diff --git a/docs/server.md b/docs/server.md deleted file mode 100644 index c7df49a..0000000 --- a/docs/server.md +++ /dev/null @@ -1,524 +0,0 @@ -# ChatKit server integration - -ChatKit's server integration offers a flexible and framework-agnostic approach for building realtime chat experiences. By implementing the `ChatKitServer` base class and its `respond` method, you can configure how your workflow responds to user inputs, from using tools to returning rich display widgets. The ChatKit server integration exposes a single endpoint and supports JSON and server‑sent events (SSE) to stream real-time updates. - -## Installation - -Install the `openai-chatkit` package with the following command: - -```bash -pip install openai-chatkit -``` - -## Defining a server class - -The `ChatKitServer` base class is the main building block of the ChatKit server implementation. - -The `respond` method is executed each time a user sends a message. It is responsible for providing an answer by streaming a set of events. The `respond` method can return assistant messages, tool status messages, workflows, tasks, and widgets. - -ChatKit also provides helpers to implement `respond` using Agents SDK. The main one is `stream_agent_response`, which converts a streamed Agents SDK run into ChatKit events. - -If you've enabled model or tool options in the composer, they'll appear in `respond` under `input_user_message.inference_options`. Your integration is responsible for handling these values when performing inference. - -Example server implementation that calls the Agent SDK runner and streams the result to the ChatKit UI: - -```python -class MyChatKitServer(ChatKitServer): - def __init__( - self, data_store: Store, attachment_store: AttachmentStore | None = None - ): - super().__init__(data_store, attachment_store) - - assistant_agent = Agent[AgentContext]( - model="gpt-4.1", - name="Assistant", - instructions="You are a helpful assistant" - ) - - async def respond( - self, - thread: ThreadMetadata, - input: UserMessageItem | None, - context: Any, - ) -> AsyncIterator[ThreadStreamEvent]: - context = AgentContext( - thread=thread, - store=self.store, - request_context=context, - ) - result = Runner.run_streamed( - self.assistant_agent, - await simple_to_agent_input(input) if input else [], - context=context, - ) - async for event in stream_agent_response( - context, - result, - ): - yield event - - # ... -``` - -## Setting up the endpoint - -ChatKit is server-agnostic. All communication happens through a single POST endpoint that returns either JSON directly or streams SSE JSON events. - -You are responsible for defining the endpoint using the web server framework of your choice. - -Example using ChatKit with FastAPI: - -```python -app = FastAPI() -data_store = PostgresStore() -attachment_store = BlobStorageStore(data_store) -server = MyChatKitServer(data_store, attachment_store) - -@app.post("/chatkit") -async def chatkit_endpoint(request: Request): - result = await server.process(await request.body(), {}) - if isinstance(result, StreamingResult): - return StreamingResponse(result, media_type="text/event-stream") - else: - return Response(content=result.json, media_type="application/json") -``` - -## Data store - -ChatKit needs to store information about threads, messages, and attachments. The examples above use a provided development-only data store implementation using SQLite (`SQLiteStore`). - -You are responsible for implementing the `chatkit.store.Store` class using the data store of your choice. When implementing the store, you must allow for the Thread/Attachment/ThreadItem type shapes changing between library versions. The recommended approach for relational databases is to serialize models into JSON-typed columns instead of separating model fields across multiple columns. - -```python -class Store(ABC, Generic[TContext]): - def generate_thread_id(self, context: TContext) -> str: ... - - def generate_item_id( - self, - item_type: Literal["message", "tool_call", "task", "workflow", "attachment"], - thread: ThreadMetadata, - context: TContext, - ) -> str: ... - - async def load_thread(self, thread_id: str, context: TContext) -> ThreadMetadata: ... - - async def save_thread(self, thread: ThreadMetadata, context: TContext) -> None: ... - - async def load_thread_items( - self, - thread_id: str, - after: str | None, - limit: int, - order: str, - context: TContext, - ) -> Page[ThreadItem]: ... - - async def save_attachment(self, attachment: Attachment, context: TContext) -> None: ... - - async def load_attachment(self, attachment_id: str, context: TContext) -> Attachment: ... - - async def delete_attachment(self, attachment_id: str, context: TContext) -> None: ... - - async def load_threads( - self, - limit: int, - after: str | None, - order: str, - context: TContext, - ) -> Page[ThreadMetadata]: ... - - async def add_thread_item( - self, thread_id: str, item: ThreadItem, context: TContext - ) -> None: ... - - async def save_item(self, thread_id: str, item: ThreadItem, context: TContext) -> None: ... - - async def load_item(self, thread_id: str, item_id: str, context: TContext) -> ThreadItem: ... - - async def delete_thread(self, thread_id: str, context: TContext) -> None: ... -``` - -The default implementation prefixes identifiers (for example `msg_4f62d6a7f2c34bd084f57cfb3df9f6bd`) using UUID4 strings. Override `generate_thread_id` and/or `generate_item_id` if your -integration needs deterministic or pre-allocated identifiers; they will be used whenever ChatKit needs to create a new thread id or a new thread item id. - -## Attachment store - -Users can upload attachments (files and images) to include with chat messages. You are responsible for providing a storage implementation and handling uploads. The `attachment_store` argument to `ChatKitServer` should implement the `AttachmentStore` interface. If not provided, operations on attachments will raise an error. - -ChatKit supports both direct uploads and two‑phase upload, configurable client-side via `ChatKitOptions.composer.attachments.uploadStrategy`. - -### Access control - -Attachment metadata and file bytes are not protected by ChatKit. Each `AttachmentStore` method receives your request context so you can enforce thread- and user-level authorization before handing out attachment IDs, bytes, or signed URLs. Deny access when the caller does not own the attachment, and generate download URLs that expire quickly. Skipping these checks can leak customer data. - -### Direct upload - -The direct upload URL is provided client-side as a create option. - -The client will POST `multipart/form-data` with a `file` field to that URL. The server should: - -1. persist the attachment metadata (`FileAttachment | ImageAttachment`) to the data store and the file bytes to your storage. -2. respond with JSON representation of `FileAttachment | ImageAttachment`. - -### Two‑phase upload - -- **Phase 1 (registration and upload URL provisioning)**: The client calls `attachments.create`. ChatKit persists a `FileAttachment | ImageAttachment` sets the `upload_url` and returns it. It's recommended to include the `id` of the `Attachment` in the `upload_url` so that you can associate the file bytes with the `Attachment`. -- **Phase 2 (upload)**: The client POSTs the bytes to the returned `upload_url` with `multipart/form-data` field `file`. - -### Previews - -To render thumbnails of an image attached to a user message, set `ImageAttachment.preview_url` to a renderable URL. If you need expiring URLs, do not persist the URL; generate it on demand when returning the attachment to the client. - -### AttachmentStore interface - -You implement the storage specifics by providing the `AttachmentStore` methods: - -```python -class AttachmentStore(ABC, Generic[TContext]): - async def delete_attachment(self, attachment_id: str, context: TContext) -> None: ... - async def create_attachment(self, input: AttachmentCreateParams, context: TContext) -> Attachment: ... - def generate_attachment_id(self, mime_type: str, context: TContext) -> str: ... -``` - -Note: The store does not have to persist bytes itself. It can act as a proxy that issues signed URLs for upload and preview (e.g., S3/GCS/Azure), while your separate upload endpoint writes to object storage. - -### Attaching files to Agent SDK inputs - -You are also responsible for deciding how to attach attachments to Agent SDK inputs. You can store files in your own storage and attach them as base64-encoded payloads or upload them to the OpenAI Files API and provide the file ID to the Agent SDK. - -The example below shows how to create base64-encoded payloads for attachments by customizing a `ThreadItemConverter`. The helper `read_attachment_bytes` stands in for whatever storage accessor you provide (for example, fetching from S3 or a database) because `AttachmentStore` only handles ChatKit protocol calls. - -```python -async def read_attachment_bytes(attachment_id: str) -> bytes: - """Replace with your blob-store fetch (S3, local disk, etc.).""" - ... - - -class MyConverter(ThreadItemConverter): - async def attachment_to_message_content( - self, input: Attachment - ) -> ResponseInputContentParam: - content = await read_attachment_bytes(input.id) - data = ( - "data:" - + str(input.mime_type) - + ";base64," - + base64.b64encode(content).decode("utf-8") - ) - if isinstance(input, ImageAttachment): - return ResponseInputImageParam( - type="input_image", - detail="auto", - image_url=data, - ) - # Note: Agents SDK currently only supports pdf files as ResponseInputFileParam. - # To send other text file types, either convert them to pdf on the fly or - # add them as input text. - return ResponseInputFileParam( - type="input_file", - file_data=data, - filename=input.name or "unknown", - ) - -# In respond(...): -result = Runner.run_streamed( - assistant_agent, - await MyConverter().to_agent_input(input), - context=context, -) -``` - -## Client tools usage - -The ChatKit server implementation can trigger client-side tools. - -The tool must be registered both when initializing ChatKit on the client and when setting up Agents SDK on the server. - -To trigger a client-side tool from Agents SDK, set `ctx.context.client_tool_call` in the tool implementation with the client-side tool name and arguments. The result of the client tool execution will be provided back to the model. - -**Note:** The agent behavior must be set to `tool_use_behavior=StopAtTools` with all client-side tools included in `stop_at_tool_names`. This causes the agent to stop generating new messages until the client tool call is acknowledged by the ChatKit UI. - -**Note:** Only one client tool call can be triggered per turn. - -**Note:** Client tools are client-side callbacks invoked by the agent during server-side inference. If you're interested in client-side callbacks triggered by a user interacting with a widget, refer to [client actions](actions.md/#client). - -```python -@function_tool(description_override="Add an item to the user's todo list.") -async def add_to_todo_list(ctx: RunContextWrapper[AgentContext], item: str) -> None: - ctx.context.client_tool_call = ClientToolCall( - name="add_to_todo_list", - arguments={"item": item}, - ) - -assistant_agent = Agent[AgentContext]( - model="gpt-4.1", - name="Assistant", - instructions="You are a helpful assistant", - tools=[add_to_todo_list], - tool_use_behavior=StopAtTools(stop_at_tool_names=[add_to_todo_list.name]), -) -``` - -## Agents SDK integration - -The ChatKit server is independent of Agents SDK. As long as correct events are returned from the `respond` method, the ChatKit UI will display the conversation as expected. - -The ChatKit library provides helpers to integrate with Agents SDK: - -- `AgentContext` - The context type that should be used when calling Agents SDK. It provides helpers to stream events from tool calls, render widgets, and initiate client tool calls. -- `stream_agent_response` - A helper to convert a streamed Agents SDK run into ChatKit events. -- `ThreadItemConverter` - A helper class that you'll probably extend to convert ChatKit thread items to Agents SDK input items. -- `simple_to_agent_input` - A helper function that uses the default thread item conversions. The default conversion is limited, but useful for getting started quickly. - -```python -async def respond([] - self, - thread: ThreadMetadata, - input: UserMessageItem | None, - context: Any, -) -> AsyncIterator[ThreadStreamEvent]: - context = AgentContext( - thread=thread, - store=self.store, - request_context=context, - ) - - result = Runner.run_streamed( - self.assistant_agent, - await simple_to_agent_input(input) if input else [], - context=context, - ) - - async for event in stream_agent_response(context, result): - yield event -``` - -### ThreadItemConverter - -Extend `ThreadItemConverter` when your integration supports: - -- Attachments -- @-mentions (entity tagging) -- `HiddenContextItem` -- Custom thread item formats - -```python -from agents import Message, Runner, ResponseInputTextParam -from chatkit.agents import AgentContext, ThreadItemConverter, stream_agent_response -from chatkit.types import Attachment, HiddenContextItem, ThreadMetadata, UserMessageItem - - -class MyThreadConverter(ThreadItemConverter): - async def attachment_to_message_content( - self, attachment: Attachment - ) -> ResponseInputTextParam: - content = await attachment_store.get_attachment_contents(attachment.id) - data_url = "data:%s;base64,%s" % (mime, base64.b64encode(raw).decode("utf-8")) - if isinstance(attachment, ImageAttachment): - return ResponseInputImageParam( - type="input_image", - detail="auto", - image_url=data_url, - ) - - # ..handle other attachment types - - async def hidden_context_to_input(self, item: HiddenContextItem) -> Message: - return Message( - type="message", - role="system", - content=[ - ResponseInputTextParam( - type="input_text", - text=f"<HIDDEN_CONTEXT>{item.content}</HIDDEN_CONTEXT>", - ) - ], - ) - - async def tag_to_message_content(self, tag: UserMessageTagContent): - tag_context = await retrieve_context_for_tag(tag.id) - return ResponseInputTextParam( - type="input_text", - text=f"<TAG>Name:{tag.data.name}\nType:{tag.data.type}\nDetails:{tag_context}</TAG>" - ) - - # ..handle other @-mentions - - # ..override defaults for other methods - -``` - -## Widgets - -Widgets are rich UI components that can be displayed in chat. You can return a widget either directly from the `respond` method (if you want to do so unconditionally) or from a tool call triggered by the model. - -Example of a widget returned directly from the `respond` method: - -```python -async def respond( - self, - thread: ThreadMetadata, - input: UserMessageItem | None, - context: Any, - ) -> AsyncIterator[ThreadStreamEvent]: - widget = Text( - id="description", - value="Text widget", - ) - - async for event in stream_widget( - thread, - widget, - generate_id=lambda item_type: self.store.generate_item_id( - item_type, thread, context - ), - ): - yield event -``` - -Example of a widget returned from a tool call: - -```python -@function_tool(description_override="Display a sample widget to the user.") -async def sample_widget(ctx: RunContextWrapper[AgentContext]) -> None: - widget = Text( - id="description", - value="Text widget", - ) - - await ctx.context.stream_widget(widget) -``` - -The examples above return a fully completed static widget. You can also stream an updating widget by yielding new versions of the widget from a generator function. The ChatKit framework will send updates for the parts of the widget that have changed. - -**Note:** Currently, only `<Text>` and `<Markdown>` components marked with an `id` have their text updates streamed. - -```python -async def sample_widget(ctx: RunContextWrapper[AgentContext]) -> None: - description_text = Runner.run_streamed( - email_generator, "ChatKit is the best thing ever" - ) - - async def widget_generator() -> AsyncGenerator[Widget, None]: - text_widget_updates = accumulate_text( - description_text.stream_events(), - Text( - id="description", - value="", - streaming=True - ), - ) - - async for text_widget in text_widget_updates: - yield Card( - children=[text_widget] - ) - - await ctx.context.stream_widget(widget_generator()) -``` - -In the example above, the `accumulate_text` function is used to stream the results of an Agents SDK run into a `Text` widget. - -### Defining a widget - -You may find it easier to write widgets in JSON. To you can parse JSON widgets to `WidgetRoot` instances for your server to stream: - -```python -try: - WidgetRoot.model_validate_json(WIDGET_JSON_STRING) -except ValidationError: - # handle invalid json -``` - -### Widget reference and examples - -See full reference of components, props, and examples in [widgets.md ➡️](./widgets.md). - -## Thread metadata - -ChatKit provides a way to store arbitrary information associated with a thread. This information is not sent to the UI. - -One use case for the metadata is to preserve the [`previous_response_id`](https://platform.openai.com/docs/api-reference/responses/create#responses-create-previous_response_id) and avoid having to re-send all items for an Agent SDK run. - -```python -previous_response_id = thread.metadata.get("previous_response_id") - -# Run the Agent SDK run with the previous response id -result = Runner.run_streamed( - agent, - input=..., - previous_response_id=previous_response_id, -) - -# Save the previous response id for the next run -thread.metadata["previous_response_id"] = result.response_id -``` - -## Automatic thread titles - -ChatKit does not automatically title threads, but you can easily implement your own logic to do so. - -First, decide when to trigger the thread title update. A simple approach might be to set the thread title the first time a user sends a message. - -```python -from chatkit.agents import simple_to_agent_input - -async def maybe_update_thread_title( - self, - thread: ThreadMetadata, - input_item: UserMessageItem, -) -> None: - if thread.title is not None: - return - agent_input = await simple_to_agent_input(input_item) - run = await Runner.run(title_agent, input=agent_input) - thread.title = run.final_output - -async def respond( - self, - thread: ThreadMetadata, - input: UserMessageItem | None, - context: Any, -) -> AsyncIterator[ThreadStreamEvent]: - if input is not None: - asyncio.create_task(self.maybe_update_thread_title(thread, input)) - - # Generate the model response - ... -``` - -## Progress updates - -If your server-side tool takes a while to run, you can use the progress update event to display the progress to the user. - -```python -@function_tool() -async def long_running_tool(ctx: RunContextWrapper[AgentContext]) -> str: - await ctx.context.stream( - ProgressUpdateEvent(text="Loading a user profile...") - ) - - await asyncio.sleep(1) -``` - -The progress update will be automatically replaced by the next assistant message, widget, or another progress update. - -## Server context - -Sometimes it's useful to pass additional information (like `userId`) to the ChatKit server implementation. The `ChatKitServer.process` method accepts a `context` parameter that it passes to the `respond` method and all data store and file store methods. - -```python -class MyChatKitServer(ChatKitServer): - async def respond(..., context) -> AsyncIterator[ThreadStreamEvent]: - # consume context["userId"] - -server.process(..., context={"userId": "user_123"}) -``` - -Server context may be used to implement permission checks in AttachmentStore and Store. - -```python -class MyChatKitServer(ChatKitServer): - async def load_attachment(..., context) -> Attachment: - # check context["userId"] has access to the file -``` diff --git a/docs/widgets.md b/docs/widgets.md deleted file mode 100644 index f120e73..0000000 --- a/docs/widgets.md +++ /dev/null @@ -1,539 +0,0 @@ -# ChatKit Widgets - -This reference is generated from the `chatkit.widgets` module. Every component inherits the common props `id`, `key`, and `type`. Optional props default to `None` unless noted. - -## Badge - -Small badge indicating status or categorization. - -| Field | Type | Default | -| --- | --- | --- | -| `key` | `str | None` | None | -| `id` | `str | None` | None | -| `type` | `Badge` | 'Badge' | -| `label` | `str` | | -| `color` | `secondary | success | danger | warning | info | discovery | None` | None | -| `variant` | `solid | soft | outline | None` | None | -| `size` | `sm | md | lg | None` | None | -| `pill` | `bool | None` | None | - -## Box - -Generic flex container with direction control. - -| Field | Type | Default | -| --- | --- | --- | -| `children` | `list['WidgetComponent'] | None` | None | -| `align` | `start | center | end | baseline | stretch | None` | None | -| `justify` | `start | center | end | between | around | evenly | stretch | None` | None | -| `wrap` | `nowrap | wrap | wrap-reverse | None` | None | -| `flex` | `int | str | None` | None | -| `gap` | `int | str | None` | None | -| `height` | `float | str | None` | None | -| `width` | `float | str | None` | None | -| `size` | `float | str | None` | None | -| `minHeight` | `int | str | None` | None | -| `minWidth` | `int | str | None` | None | -| `minSize` | `int | str | None` | None | -| `maxHeight` | `int | str | None` | None | -| `maxWidth` | `int | str | None` | None | -| `maxSize` | `int | str | None` | None | -| `padding` | `float | str | Spacing | None` | None | -| `margin` | `float | str | Spacing | None` | None | -| `border` | `int | Border | Borders | None` | None | -| `radius` | `2xs | xs | sm | md | lg | xl | 2xl | 3xl | 4xl | full | 100% | none | None` | None | -| `background` | `str | ThemeColor | None` | None | -| `aspectRatio` | `float | str | None` | None | -| `key` | `str | None` | None | -| `id` | `str | None` | None | -| `type` | `Box` | 'Box' | -| `direction` | `row | col | None` | None | - -## Button - -Button component optionally wired to an action. - -| Field | Type | Default | -| --- | --- | --- | -| `key` | `str | None` | None | -| `id` | `str | None` | None | -| `type` | `Button` | 'Button' | -| `submit` | `bool | None` | None | -| `label` | `str | None` | None | -| `onClickAction` | `ActionConfig | None` | None | -| `iconStart` | `WidgetIcon | None` | None | -| `iconEnd` | `WidgetIcon | None` | None | -| `style` | `primary | secondary | None` | None | -| `iconSize` | `sm | md | lg | xl | 2xl | None` | None | -| `color` | `primary | secondary | info | discovery | success | caution | warning | danger | None` | None | -| `variant` | `solid | soft | outline | ghost | None` | None | -| `size` | `3xs | 2xs | xs | sm | md | lg | xl | 2xl | 3xl | None` | None | -| `pill` | `bool | None` | None | -| `uniform` | `bool | None` | None | -| `block` | `bool | None` | None | -| `disabled` | `bool | None` | None | - -## Caption - -Widget rendering supporting caption text. - -| Field | Type | Default | -| --- | --- | --- | -| `key` | `str | None` | None | -| `id` | `str | None` | None | -| `type` | `Caption` | 'Caption' | -| `value` | `str` | | -| `color` | `str | ThemeColor | None` | None | -| `weight` | `normal | medium | semibold | bold | None` | None | -| `size` | `sm | md | lg | None` | None | -| `textAlign` | `start | center | end | None` | None | -| `truncate` | `bool | None` | None | -| `maxLines` | `int | None` | None | - -## Card - -Versatile container used for structuring widget content. - -| Field | Type | Default | -| --- | --- | --- | -| `key` | `str | None` | None | -| `id` | `str | None` | None | -| `type` | `Card` | 'Card' | -| `asForm` | `bool | None` | None | -| `children` | `list['WidgetComponent']` | | -| `background` | `str | ThemeColor | None` | None | -| `size` | `sm | md | lg | full | None` | None | -| `padding` | `float | str | Spacing | None` | None | -| `status` | `WidgetStatusWithFavicon | WidgetStatusWithIcon | None` | None | -| `collapsed` | `bool | None` | None | -| `confirm` | `CardAction | None` | None | -| `cancel` | `CardAction | None` | None | -| `theme` | `light | dark | None` | None | - -## Chart - -Data visualization component for simple bar/line/area charts. - -| Field | Type | Default | -| --- | --- | --- | -| `key` | `str | None` | None | -| `id` | `str | None` | None | -| `type` | `Chart` | 'Chart' | -| `data` | `list[dict[str, str | int | float]]` | | -| `series` | `list[Series]` | | -| `xAxis` | `str | XAxisConfig` | | -| `showYAxis` | `bool | None` | None | -| `showLegend` | `bool | None` | None | -| `showTooltip` | `bool | None` | None | -| `barGap` | `int | None` | None | -| `barCategoryGap` | `int | None` | None | -| `flex` | `int | str | None` | None | -| `height` | `int | str | None` | None | -| `width` | `int | str | None` | None | -| `size` | `int | str | None` | None | -| `minHeight` | `int | str | None` | None | -| `minWidth` | `int | str | None` | None | -| `minSize` | `int | str | None` | None | -| `maxHeight` | `int | str | None` | None | -| `maxWidth` | `int | str | None` | None | -| `maxSize` | `int | str | None` | None | -| `aspectRatio` | `float | str | None` | None | - -## Checkbox - -Checkbox input component. - -| Field | Type | Default | -| --- | --- | --- | -| `key` | `str | None` | None | -| `id` | `str | None` | None | -| `type` | `Checkbox` | 'Checkbox' | -| `name` | `str` | | -| `label` | `str | None` | None | -| `defaultChecked` | `bool | None` | None | -| `onChangeAction` | `ActionConfig | None` | None | -| `disabled` | `bool | None` | None | -| `required` | `bool | None` | None | - -## Col - -Vertical flex container. - -| Field | Type | Default | -| --- | --- | --- | -| `children` | `list['WidgetComponent'] | None` | None | -| `align` | `start | center | end | baseline | stretch | None` | None | -| `justify` | `start | center | end | between | around | evenly | stretch | None` | None | -| `wrap` | `nowrap | wrap | wrap-reverse | None` | None | -| `flex` | `int | str | None` | None | -| `gap` | `int | str | None` | None | -| `height` | `float | str | None` | None | -| `width` | `float | str | None` | None | -| `size` | `float | str | None` | None | -| `minHeight` | `int | str | None` | None | -| `minWidth` | `int | str | None` | None | -| `minSize` | `int | str | None` | None | -| `maxHeight` | `int | str | None` | None | -| `maxWidth` | `int | str | None` | None | -| `maxSize` | `int | str | None` | None | -| `padding` | `float | str | Spacing | None` | None | -| `margin` | `float | str | Spacing | None` | None | -| `border` | `int | Border | Borders | None` | None | -| `radius` | `2xs | xs | sm | md | lg | xl | 2xl | 3xl | 4xl | full | 100% | none | None` | None | -| `background` | `str | ThemeColor | None` | None | -| `aspectRatio` | `float | str | None` | None | -| `key` | `str | None` | None | -| `id` | `str | None` | None | -| `type` | `Col` | 'Col' | - -## DatePicker - -Date picker input component. - -| Field | Type | Default | -| --- | --- | --- | -| `key` | `str | None` | None | -| `id` | `str | None` | None | -| `type` | `DatePicker` | 'DatePicker' | -| `name` | `str` | | -| `onChangeAction` | `ActionConfig | None` | None | -| `placeholder` | `str | None` | None | -| `defaultValue` | `datetime | None` | None | -| `min` | `datetime | None` | None | -| `max` | `datetime | None` | None | -| `variant` | `solid | soft | outline | ghost | None` | None | -| `size` | `3xs | 2xs | xs | sm | md | lg | xl | 2xl | 3xl | None` | None | -| `side` | `top | bottom | left | right | None` | None | -| `align` | `start | center | end | None` | None | -| `pill` | `bool | None` | None | -| `block` | `bool | None` | None | -| `clearable` | `bool | None` | None | -| `disabled` | `bool | None` | None | - -## Divider - -Visual divider separating content sections. - -| Field | Type | Default | -| --- | --- | --- | -| `key` | `str | None` | None | -| `id` | `str | None` | None | -| `type` | `Divider` | 'Divider' | -| `color` | `str | ThemeColor | None` | None | -| `size` | `int | str | None` | None | -| `spacing` | `int | str | None` | None | -| `flush` | `bool | None` | None | - -## Form - -Form wrapper capable of submitting ``onSubmitAction``. - -| Field | Type | Default | -| --- | --- | --- | -| `children` | `list['WidgetComponent'] | None` | None | -| `align` | `start | center | end | baseline | stretch | None` | None | -| `justify` | `start | center | end | between | around | evenly | stretch | None` | None | -| `wrap` | `nowrap | wrap | wrap-reverse | None` | None | -| `flex` | `int | str | None` | None | -| `gap` | `int | str | None` | None | -| `height` | `float | str | None` | None | -| `width` | `float | str | None` | None | -| `size` | `float | str | None` | None | -| `minHeight` | `int | str | None` | None | -| `minWidth` | `int | str | None` | None | -| `minSize` | `int | str | None` | None | -| `maxHeight` | `int | str | None` | None | -| `maxWidth` | `int | str | None` | None | -| `maxSize` | `int | str | None` | None | -| `padding` | `float | str | Spacing | None` | None | -| `margin` | `float | str | Spacing | None` | None | -| `border` | `int | Border | Borders | None` | None | -| `radius` | `2xs | xs | sm | md | lg | xl | 2xl | 3xl | 4xl | full | 100% | none | None` | None | -| `background` | `str | ThemeColor | None` | None | -| `aspectRatio` | `float | str | None` | None | -| `key` | `str | None` | None | -| `id` | `str | None` | None | -| `type` | `Form` | 'Form' | -| `onSubmitAction` | `ActionConfig | None` | None | -| `direction` | `row | col | None` | None | - -## Icon - -Icon component referencing a built-in icon name. - -| Field | Type | Default | -| --- | --- | --- | -| `key` | `str | None` | None | -| `id` | `str | None` | None | -| `type` | `Icon` | 'Icon' | -| `name` | `WidgetIcon` | | -| `color` | `str | ThemeColor | None` | None | -| `size` | `xs | sm | md | lg | xl | 2xl | 3xl | None` | None | - -## Image - -Image component with sizing and fitting controls. - -| Field | Type | Default | -| --- | --- | --- | -| `key` | `str | None` | None | -| `id` | `str | None` | None | -| `type` | `Image` | 'Image' | -| `src` | `str` | | -| `alt` | `str | None` | None | -| `fit` | `cover | contain | fill | scale-down | none | None` | None | -| `position` | `top left | top | top right | left | center | right | bottom left | bottom | bottom right | None` | None | -| `radius` | `2xs | xs | sm | md | lg | xl | 2xl | 3xl | 4xl | full | 100% | none | None` | None | -| `frame` | `bool | None` | None | -| `flush` | `bool | None` | None | -| `height` | `int | str | None` | None | -| `width` | `int | str | None` | None | -| `size` | `int | str | None` | None | -| `minHeight` | `int | str | None` | None | -| `minWidth` | `int | str | None` | None | -| `minSize` | `int | str | None` | None | -| `maxHeight` | `int | str | None` | None | -| `maxWidth` | `int | str | None` | None | -| `maxSize` | `int | str | None` | None | -| `margin` | `int | str | Spacing | None` | None | -| `background` | `str | ThemeColor | None` | None | -| `aspectRatio` | `float | str | None` | None | -| `flex` | `int | str | None` | None | - -## Input - -Single-line text input component. - -| Field | Type | Default | -| --- | --- | --- | -| `key` | `str | None` | None | -| `id` | `str | None` | None | -| `type` | `Input` | 'Input' | -| `name` | `str` | | -| `inputType` | `number | email | text | password | tel | url | None` | None | -| `defaultValue` | `str | None` | None | -| `required` | `bool | None` | None | -| `pattern` | `str | None` | None | -| `placeholder` | `str | None` | None | -| `allowAutofillExtensions` | `bool | None` | None | -| `autoSelect` | `bool | None` | None | -| `autoFocus` | `bool | None` | None | -| `disabled` | `bool | None` | None | -| `variant` | `soft | outline | None` | None | -| `size` | `3xs | 2xs | xs | sm | md | lg | xl | 2xl | 3xl | None` | None | -| `gutterSize` | `2xs | xs | sm | md | lg | xl | None` | None | -| `pill` | `bool | None` | None | - -## Label - -Form label associated with a field. - -| Field | Type | Default | -| --- | --- | --- | -| `key` | `str | None` | None | -| `id` | `str | None` | None | -| `type` | `Label` | 'Label' | -| `value` | `str` | | -| `fieldName` | `str` | | -| `size` | `xs | sm | md | lg | xl | None` | None | -| `weight` | `normal | medium | semibold | bold | None` | None | -| `textAlign` | `start | center | end | None` | None | -| `color` | `str | ThemeColor | None` | None | - -## ListView - -Container component for rendering collections of list items. - -| Field | Type | Default | -| --- | --- | --- | -| `key` | `str | None` | None | -| `id` | `str | None` | None | -| `type` | `ListView` | 'ListView' | -| `children` | `list[ListViewItem]` | | -| `limit` | `int | auto | None` | None | -| `status` | `WidgetStatusWithFavicon | WidgetStatusWithIcon | None` | None | -| `theme` | `light | dark | None` | None | - -## ListViewItem - -Single row inside a ``ListView`` component. - -| Field | Type | Default | -| --- | --- | --- | -| `key` | `str | None` | None | -| `id` | `str | None` | None | -| `type` | `ListViewItem` | 'ListViewItem' | -| `children` | `list['WidgetComponent']` | | -| `onClickAction` | `ActionConfig | None` | None | -| `gap` | `int | str | None` | None | -| `align` | `start | center | end | baseline | stretch | None` | None | - -## Markdown - -Widget rendering Markdown content, optionally streamed. - -| Field | Type | Default | -| --- | --- | --- | -| `key` | `str | None` | None | -| `id` | `str | None` | None | -| `type` | `Markdown` | 'Markdown' | -| `value` | `str` | | -| `streaming` | `bool | None` | None | - -## RadioGroup - -Grouped radio input control. - -| Field | Type | Default | -| --- | --- | --- | -| `key` | `str | None` | None | -| `id` | `str | None` | None | -| `type` | `RadioGroup` | 'RadioGroup' | -| `name` | `str` | | -| `options` | `list[RadioOption] | None` | None | -| `ariaLabel` | `str | None` | None | -| `onChangeAction` | `ActionConfig | None` | None | -| `defaultValue` | `str | None` | None | -| `direction` | `row | col | None` | None | -| `disabled` | `bool | None` | None | -| `required` | `bool | None` | None | - -## Row - -Horizontal flex container. - -| Field | Type | Default | -| --- | --- | --- | -| `children` | `list['WidgetComponent'] | None` | None | -| `align` | `start | center | end | baseline | stretch | None` | None | -| `justify` | `start | center | end | between | around | evenly | stretch | None` | None | -| `wrap` | `nowrap | wrap | wrap-reverse | None` | None | -| `flex` | `int | str | None` | None | -| `gap` | `int | str | None` | None | -| `height` | `float | str | None` | None | -| `width` | `float | str | None` | None | -| `size` | `float | str | None` | None | -| `minHeight` | `int | str | None` | None | -| `minWidth` | `int | str | None` | None | -| `minSize` | `int | str | None` | None | -| `maxHeight` | `int | str | None` | None | -| `maxWidth` | `int | str | None` | None | -| `maxSize` | `int | str | None` | None | -| `padding` | `float | str | Spacing | None` | None | -| `margin` | `float | str | Spacing | None` | None | -| `border` | `int | Border | Borders | None` | None | -| `radius` | `2xs | xs | sm | md | lg | xl | 2xl | 3xl | 4xl | full | 100% | none | None` | None | -| `background` | `str | ThemeColor | None` | None | -| `aspectRatio` | `float | str | None` | None | -| `key` | `str | None` | None | -| `id` | `str | None` | None | -| `type` | `Row` | 'Row' | - -## Select - -Select dropdown component. - -| Field | Type | Default | -| --- | --- | --- | -| `key` | `str | None` | None | -| `id` | `str | None` | None | -| `type` | `Select` | 'Select' | -| `name` | `str` | | -| `options` | `list[SelectOption]` | | -| `onChangeAction` | `ActionConfig | None` | None | -| `placeholder` | `str | None` | None | -| `defaultValue` | `str | None` | None | -| `variant` | `solid | soft | outline | ghost | None` | None | -| `size` | `3xs | 2xs | xs | sm | md | lg | xl | 2xl | 3xl | None` | None | -| `pill` | `bool | None` | None | -| `block` | `bool | None` | None | -| `clearable` | `bool | None` | None | -| `disabled` | `bool | None` | None | - -## Spacer - -Flexible spacer used to push content apart. - -| Field | Type | Default | -| --- | --- | --- | -| `key` | `str | None` | None | -| `id` | `str | None` | None | -| `type` | `Spacer` | 'Spacer' | -| `minSize` | `int | str | None` | None | - -## Text - -Widget rendering plain text with typography controls. - -| Field | Type | Default | -| --- | --- | --- | -| `key` | `str | None` | None | -| `id` | `str | None` | None | -| `type` | `Text` | 'Text' | -| `value` | `str` | | -| `streaming` | `bool | None` | None | -| `italic` | `bool | None` | None | -| `lineThrough` | `bool | None` | None | -| `color` | `str | ThemeColor | None` | None | -| `weight` | `normal | medium | semibold | bold | None` | None | -| `width` | `float | str | None` | None | -| `size` | `xs | sm | md | lg | xl | None` | None | -| `textAlign` | `start | center | end | None` | None | -| `truncate` | `bool | None` | None | -| `minLines` | `int | None` | None | -| `maxLines` | `int | None` | None | -| `editable` | `False | EditableProps | None` | None | - -## Textarea - -Multiline text input component. - -| Field | Type | Default | -| --- | --- | --- | -| `key` | `str | None` | None | -| `id` | `str | None` | None | -| `type` | `Textarea` | 'Textarea' | -| `name` | `str` | | -| `defaultValue` | `str | None` | None | -| `required` | `bool | None` | None | -| `pattern` | `str | None` | None | -| `placeholder` | `str | None` | None | -| `autoSelect` | `bool | None` | None | -| `autoFocus` | `bool | None` | None | -| `disabled` | `bool | None` | None | -| `variant` | `soft | outline | None` | None | -| `size` | `3xs | 2xs | xs | sm | md | lg | xl | 2xl | 3xl | None` | None | -| `gutterSize` | `2xs | xs | sm | md | lg | xl | None` | None | -| `rows` | `int | None` | None | -| `autoResize` | `bool | None` | None | -| `maxRows` | `int | None` | None | -| `allowAutofillExtensions` | `bool | None` | None | - -## Title - -Widget rendering prominent headline text. - -| Field | Type | Default | -| --- | --- | --- | -| `key` | `str | None` | None | -| `id` | `str | None` | None | -| `type` | `Title` | 'Title' | -| `value` | `str` | | -| `color` | `str | ThemeColor | None` | None | -| `weight` | `normal | medium | semibold | bold | None` | None | -| `size` | `sm | md | lg | xl | 2xl | 3xl | 4xl | 5xl | None` | None | -| `textAlign` | `start | center | end | None` | None | -| `truncate` | `bool | None` | None | -| `maxLines` | `int | None` | None | - -## Transition - -Wrapper enabling transitions for a child component. - -| Field | Type | Default | -| --- | --- | --- | -| `key` | `str | None` | None | -| `id` | `str | None` | None | -| `type` | `Transition` | 'Transition' | -| `children` | `WidgetComponent | None` | | - diff --git a/mkdocs.yml b/mkdocs.yml index 6e0c7e3..eaedcd3 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -40,9 +40,24 @@ plugins: nav: - Home: index.md - Guides: - - Server Integration: server.md - - Actions: actions.md - - Widgets: widgets.md + - Serve ChatKit from your backend: guides/serve-chatkit.md + - Persist ChatKit threads and messages: guides/persist-chatkit-data.md + - Convert user input to model input: guides/convert-user-input.md + - Run inference: guides/run-inference.md + - Stream responses back to your user: guides/stream-thread-events.md + - Add features: + - Save thread titles: guides/add-features/save-thread-titles.md + - Accept attachments: guides/add-features/accept-attachments.md + - Use client tool calls: guides/add-features/use-client-tool-calls.md + - Send client effects: guides/add-features/send-client-effects.md + - Stream widgets: guides/add-features/stream-widgets.md + - Handle widget actions: guides/add-features/handle-widget-actions.md + - Create custom forms: guides/add-features/create-custom-forms.md + # - Allow @-mentions in user messages + # - Add citations and sources in assistant messages + # - Show progress for long-running tools + # - Lock threads + # - Handle feedback - API Reference: - Overview: api/chatkit/index.md - Modules: From 6579cd3a6149aa7b4d4801ab246de3ee117dc491 Mon Sep 17 00:00:00 2001 From: Jiwon Kim <jiwon@openai.com> Date: Mon, 1 Dec 2025 12:48:08 -0800 Subject: [PATCH 2/2] Linked sections --- ...lient-tool-calls.md => make-client-tool-calls.md} | 2 +- docs/guides/convert-user-input.md | 4 ++++ docs/guides/persist-chatkit-data.md | 4 ++++ docs/guides/run-inference.md | 4 ++++ docs/guides/serve-chatkit.md | 4 ++++ docs/guides/stream-thread-events.md | 12 ++++++++++++ mkdocs.yml | 2 +- 7 files changed, 30 insertions(+), 2 deletions(-) rename docs/guides/add-features/{use-client-tool-calls.md => make-client-tool-calls.md} (99%) diff --git a/docs/guides/add-features/use-client-tool-calls.md b/docs/guides/add-features/make-client-tool-calls.md similarity index 99% rename from docs/guides/add-features/use-client-tool-calls.md rename to docs/guides/add-features/make-client-tool-calls.md index b02de45..b4a2a68 100644 --- a/docs/guides/add-features/use-client-tool-calls.md +++ b/docs/guides/add-features/make-client-tool-calls.md @@ -1,4 +1,4 @@ -# Use client tool calls +# Make client tool calls Client tool calls let the agent invoke browser/app callbacks mid-inference. Register the tool on both client and server; when triggered, ChatKit pauses the model, sends the tool request to the client, and resumes with the returned result. diff --git a/docs/guides/convert-user-input.md b/docs/guides/convert-user-input.md index 52864a4..21b4a17 100644 --- a/docs/guides/convert-user-input.md +++ b/docs/guides/convert-user-input.md @@ -83,3 +83,7 @@ if input and input.inference_options: tool_choice = input.inference_options.tool_choice # forward these into your inference call ``` + +## Next + +[Run inference](run-inference.md) \ No newline at end of file diff --git a/docs/guides/persist-chatkit-data.md b/docs/guides/persist-chatkit-data.md index 00e9317..c553319 100644 --- a/docs/guides/persist-chatkit-data.md +++ b/docs/guides/persist-chatkit-data.md @@ -111,3 +111,7 @@ result = Runner.run_streamed( thread.metadata["previous_response_id"] = result.response_id ``` + +## Next + +[Convert user input to model input](convert-user-input.md) \ No newline at end of file diff --git a/docs/guides/run-inference.md b/docs/guides/run-inference.md index 2c07c4e..258a07d 100644 --- a/docs/guides/run-inference.md +++ b/docs/guides/run-inference.md @@ -65,3 +65,7 @@ You can supply additional reference context to the model at inference time using - Use server tools when the reference content lives on the backend and can be retrieved during inference. - Use client tool calls when the browser or app must supply transient state (for example, active UI selections). - Manually inject additional model input items when the reference content is already available at inference time and your application is latency-sensitive. + +## Next + +[Stream responses back to your user](stream-thread-events.md) \ No newline at end of file diff --git a/docs/guides/serve-chatkit.md b/docs/guides/serve-chatkit.md index 8a88ffc..079fb31 100644 --- a/docs/guides/serve-chatkit.md +++ b/docs/guides/serve-chatkit.md @@ -92,3 +92,7 @@ async def chatkit_endpoint(request: Request): Every ChatKit request payload includes a `metadata` field you can use to carry per-request context from the client. Pull it from the request in your endpoint before calling server.process to use it for auth/tracing/business logic there, or to include it in the context you pass through so respond and tools can read it. + +## Next + +[Persist ChatKit threads and messages](persist-chatkit-data.md) \ No newline at end of file diff --git a/docs/guides/stream-thread-events.md b/docs/guides/stream-thread-events.md index 8244fdb..6655418 100644 --- a/docs/guides/stream-thread-events.md +++ b/docs/guides/stream-thread-events.md @@ -155,3 +155,15 @@ Use `ClientEffectEvent` to trigger fire-and-forget behavior on the client such a ## Stream options `StreamOptionsEvent` configures runtime stream behavior (for example, allowing user cancellation). `ChatKitServer` emits one at the start of every stream using `get_stream_options`; override that method to change defaults such as `allow_cancel`. + +## Next + +Add features: + +* [Save thread titles](add-features/save-thread-titles.md) +* [Accept attachments](add-features/accept-attachments.md) +* [Make client tool calls](add-features/make-client-tool-calls.md) +* [Send client effects](add-features/send-client-effects.md) +* [Stream widgets](add-features/stream-widgets.md) +* [Handle widget actionss](add-features/handle-widget-actions.md) +* [Create custom forms](add-features/create-custom-forms.md) \ No newline at end of file diff --git a/mkdocs.yml b/mkdocs.yml index eaedcd3..c6985af 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -48,7 +48,7 @@ nav: - Add features: - Save thread titles: guides/add-features/save-thread-titles.md - Accept attachments: guides/add-features/accept-attachments.md - - Use client tool calls: guides/add-features/use-client-tool-calls.md + - Make client tool calls: guides/add-features/make-client-tool-calls.md - Send client effects: guides/add-features/send-client-effects.md - Stream widgets: guides/add-features/stream-widgets.md - Handle widget actions: guides/add-features/handle-widget-actions.md