Skip to main content
Lesson 20 of 25 · Building OpenAI Agents

User Actions

Lesson 20 of 25 Updated November 3, 2026 Developer
On this page
  1. What is an action?
  2. Processing an action on the server
  3. Forms
  4. Confirm before acting
  5. Processing an action in the browser
  6. Loading states
  7. Key takeaways

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 action method of ChatKitServer, or in the browser if you specify otherwise. This lesson follows the Actions in ChatKit guide and the openai-chatkit SDK 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:

FieldRole
typeThe action name your code tests: "order.cancel"
payloadUseful data: {"number": "C-42"}
handler"server" (default) or "client" to process it in the browser
loadingBehaviorThe 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_id in 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 HiddenContextItem adds 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_widget as 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:

ValueEffect
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
selfLoading state on the single component holding the action
containerThe entire widget fades and becomes inactive
noneNo 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 action method of ChatKitServer receives it and returns events: message, updated widget, or new agent response via respond
  • The payload comes from the browser: re-verify permissions and data on the server (Pydantic), even when the form already validates required and pattern
  • 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)