User Actions
On this page
Lesson rewritten November 3, 2026. The previous version handled clicks in custom React components and invented “suggestion actions.” In ChatKit, an action is an object attached to a widget component (button, list row, form). By default, it is processed on your server in the
actionmethod ofChatKitServer, or in the browser if you specify otherwise. This lesson follows the Actions in ChatKit guide and theopenai-chatkitSDK 1.6.5. The server, templates, and form were run locally; the React code was verified by compilation. Sources: Actions in ChatKit · Build interactive responses with widgets · Python SDK 1.6.2 release notes.
What is an action?
A widget that only displays content is just a pretty response. Actions make it useful: clicking “Cancel Order,” selecting a row in a list, or submitting a form triggers processing without the user having to type a message. ChatKit sends the action to your server, which can act on your systems, update the widget, add a response to the thread, or restart the agent.
You declare an action in the widget template on a component that accepts one (onClickAction for a Button or ListViewItem, onChangeAction for a Select, onSubmitAction for a Form, confirm and cancel for a Card). It is a small JSON object:
| Field | Role |
|---|---|
type | The action name your code tests: "order.cancel" |
payload | Useful data: {"number": "C-42"} |
handler | "server" (default) or "client" to process it in the browser |
loadingBehavior | The loading state displayed during processing (see below) |
Processing an action on the server
Let’s take an order card that offers cancellation. The template declares the action on the confirm and cancel buttons of the Card:
CANCEL_TEMPLATE = """
{
"type": "Card",
"children": [
{"type": "Title", "value": {{ ("Order " ~ number) | tojson }}, "size": "sm"},
{"type": "Text", "value": "Do you really want to cancel this order?"}
],
"confirm": {"label": "Cancel Order",
"action": {"type": "order.cancel", "payload": {"number": {{ number | tojson }}}}},
"cancel": {"label": "Keep Order",
"action": {"type": "order.keep", "payload": {"number": {{ number | tojson }}}}}
}
"""
On the server side, add an action method to your ChatKitServer. It receives the thread, the action, the widget that emitted it (sender), and your context, and returns events like respond:
from datetime import datetime
from typing import Any, AsyncIterator
from chatkit.actions import Action
from chatkit.server import ChatKitServer
from chatkit.types import (
AssistantMessageContent,
AssistantMessageItem,
HiddenContextItem,
ThreadItemDoneEvent,
ThreadMetadata,
ThreadStreamEvent,
WidgetItem,
)
class MyChatKitServer(ChatKitServer[dict[str, Any]]):
# respond(...) : as in lesson 17
def message(self, thread: ThreadMetadata, context: dict[str, Any], text: str) -> ThreadItemDoneEvent:
return ThreadItemDoneEvent(
item=AssistantMessageItem(
thread_id=thread.id,
id=self.store.generate_item_id("message", thread, context),
created_at=datetime.now(),
content=[AssistantMessageContent(text=text)],
)
)
async def action(
self,
thread: ThreadMetadata,
action: Action[str, Any],
sender: WidgetItem | None,
context: dict[str, Any],
) -> AsyncIterator[ThreadStreamEvent]:
if action.type == "order.cancel":
number = str((action.payload or {}).get("number", ""))
# The payload comes from the browser: re-verify permissions
if not await is_user_order(number, context.get("user_id")):
yield self.message(thread, context, "This order does not belong to you.")
return
await cancel_order(number)
# A trace for the model, never shown to the user
await self.store.add_thread_item(
thread.id,
HiddenContextItem(
id=self.store.generate_item_id("message", thread, context),
thread_id=thread.id,
created_at=datetime.now(),
content=f"<USER_ACTION>User canceled order {number}.</USER_ACTION>",
),
context,
)
yield self.message(thread, context, f"Done: order {number} is canceled.")
elif action.type == "order.keep":
yield self.message(thread, context, "Very well, the order is kept.")
Three rules emerge from this code:
- The payload is untrusted. It arrives from the browser, where anyone can modify it: the OpenAI guide requires treating it as unverified data. Before canceling, the server checks that the order belongs to the authenticated user. The
user_idin the context is the one your authenticated endpoint places there (lesson 21). - The model must know what happened. A click is not a message: without a trace, the agent would ignore at the next turn that the order is canceled. A
HiddenContextItemadds this information to the thread without displaying it. The SDK automatically records only broadcast elements, and a hidden element must never be broadcast: you must add it yourself to the store. - The response is a stream of events. Here a simple message; it could be an updated widget, broadcast with
stream_widgetas in the previous lesson.
To have the agent comment on what just happened, restart it after the action: async for event in self.respond(thread, None, context): yield event. Your respond method rereads the thread, finds the hidden element, and responds accordingly.
Forms
When input fields (Input, Textarea, Select, Checkbox, DatePicker…) are placed inside a Form, their values are added to the payload of any action emitted from that form, under each field’s name:
REPORT_TEMPLATE = """
{
"type": "Card",
"children": [
{"type": "Form", "direction": "col", "gap": 3,
"onSubmitAction": {"type": "ticket.create", "payload": {"source": "chat"}},
"children": [
{"type": "Title", "value": "Report an Issue", "size": "sm"},
{"type": "Label", "value": "Order Number", "fieldName": "order"},
{"type": "Input", "name": "order", "required": true, "pattern": "C-[0-9]+", "placeholder": "C-42"},
{"type": "Label", "value": "Category", "fieldName": "category"},
{"type": "Select", "name": "category", "placeholder": "Select",
"options": [{"label": "Damaged Package", "value": "damaged"},
{"label": "Delay", "value": "delay"},
{"label": "Other", "value": "other"}]},
{"type": "Textarea", "name": "detail", "rows": 3, "placeholder": "Describe the issue"},
{"type": "Button", "label": "Submit", "submit": true}
]}
]
}
"""
On submission, action.payload contains source, order, category, and detail. A dotted name, "client.name", produces a nested value: payload["client"]["name"]. If a field name collides with a key already present in the payload, the field value is ignored and ChatKit emits an error event: choose distinct names.
Form validation is browser-side: required and pattern block submission while a field is invalid. This is user convenience, not protection: re-validate on the server, for example with a Pydantic model.
from typing import Literal
from pydantic import BaseModel, Field, ValidationError
class Report(BaseModel):
order: str = Field(pattern=r"^C-[0-9]+$")
category: Literal["damaged", "delay", "other"]
detail: str = Field(default="", max_length=2000)
# In the action method
if action.type == "ticket.create":
try:
report = Report.model_validate(action.payload or {})
except ValidationError:
yield self.message(thread, context, "The form is incomplete or invalid.")
return
ticket_number = await create_ticket(report, context.get("user_id"))
yield self.message(thread, context, f"Ticket {ticket_number} created, we will get back to you.")
For richer validation, with custom error messages, the documentation advises moving outside the widget: a client-side action opens a window in your application, and the result returns to ChatKit via sendCustomAction. Note also that a Card declared with "asForm": true behaves like a form: its fields are validated and passed to its confirm action.
Confirm before acting
The cancellation card at the beginning illustrates a rule to generalize: an irreversible or sensitive action requires explicit confirmation. Canceling an order, sending an email, refunding, deleting: the agent proposes, the user validates on a card that clearly states what will happen, and it is your server, not the model, that executes after re-verifying permissions. The confirm and cancel buttons of a Card are made for this. Their default loading state grays out the entire card during processing, which prevents double-clicks.
Processing an action in the browser
Some actions have nothing to do on the server: opening a page in your application, copying a link, displaying a modal. Declare them with "handler": "client" in the template, for example {"type": "order.track", "handler": "client", "payload": {"number": "C-42"}}. They then arrive in the widgets.onAction option of the interface:
import { ChatKit, useChatKit } from '@openai/chatkit-react';
export function AssistantOrders() {
const chatkit = useChatKit({
api: { url: '/chatkit', domainKey: 'local-dev' },
widgets: {
onAction: async (action, widgetItem) => {
if (action.type === 'order.track') {
window.open(`/orders/${String(action.payload?.number ?? '')}`, '_blank');
// Notify the server, which can update the widget
await chatkit.sendCustomAction(
{ type: 'order.tracked', payload: action.payload },
widgetItem.id,
);
}
},
},
});
return <ChatKit control={chatkit.control} className="h-[600px] w-[360px]" />;
}
sendCustomAction sends an action to your server as if it came from a widget. It also works outside ChatKit: a button on your page can call chatkit.sendCustomAction({ type: 'cart.empty' }) so the assistant reacts to what is happening elsewhere in the application.
Loading states
loadingBehavior controls what the user sees while an action is processing:
| Value | Effect |
|---|---|
auto (default) | Adapts to the component: the button alone for a Button, nothing for a Select, the whole card for the confirm of a Card |
self | Loading state on the single component holding the action |
container | The entire widget fades and becomes inactive |
none | No loading state |
The documentation recommends keeping auto, unless you have a specific need. By default, actions on a widget are also blocked while a response is streaming, to avoid conflicts. Since SDK version 1.6.2, an action declared with "streaming": false bypasses this blocking: it is processed by the server’s sync_action method, reserved for actions that only update their own widget or act outside the thread.
Key takeaways
- An action is an object
{type, payload, handler, loadingBehavior}attached to a widget component; it triggers processing without a user message - On the server, the
actionmethod ofChatKitServerreceives it and returns events: message, updated widget, or new agent response viarespond - The payload comes from the browser: re-verify permissions and data on the server (Pydantic), even when the form already validates
requiredandpattern - A
HiddenContextItem, manually added to the store, informs the model of what the user did - Sensitive actions go through a confirmation card; purely local actions are processed in the browser (
handler: "client",widgets.onAction,sendCustomAction)