Interactive Widgets
On this page
Lesson rewritten November 3, 2026. The previous version described widgets as React components written by the developer and registered in the interface, using a non-existent
@openai/chatkit/reactmodule. In ChatKit, a widget is a declarative description that your server builds and that ChatKit renders itself. This lesson follows theopenai-chatkitPython SDK 1.6.5, which builds widgets from.widgettemplates: itsCard,Text,Badge… classes are deprecated. The examples were executed on a local ChatKit server. Sources: ChatKit widgets · Widgets (Python SDK) · Build interactive responses with widgets · Widget Builder.
What is a ChatKit widget?
A text response is enough for explanations. But to present an order, compare three plans, or confirm an appointment, text quickly becomes a wall of lines the user has to reread. Widgets replace that text with an interface: a card, a list, a form, or a chart, displayed within the conversation flow.
The approach is declarative. Your server delivers neither HTML nor a React component: it describes the widget in JSON, using a closed set of components and properties, and sends it into the thread as a full-fledged element (a WidgetItem). ChatKit draws it with its own components, using the theme colors from the previous lesson. Two consequences follow:
- You can only use components and properties that ChatKit knows. In return, the rendering stays consistent with the rest of the interface, and no arbitrary HTML or JavaScript from the server reaches the page.
- The widget is part of the history: in subsequent turns, it is converted into input for the model, which therefore “sees” what it showed the user.
Containers and components
Every widget has a root, chosen from three containers:
| Container | Usage |
|---|---|
Card | Standalone content: a summary, a confirmation, a form. Accepts a status and confirmation/cancel buttons below the content |
ListView | A set of options: restaurants, files, orders. Its children are ListViewItems, with built-in “see more” functionality (limit) |
Basic | Reserved for entity previews displayed when hovering over a mention |
Inside, you assemble components: text (Title, Text, Caption, Markdown), labels (Badge), layout (Row, Col, Box, Spacer, Divider), media (Image, Icon), commands (Button, Select, DatePicker, Checkbox, RadioGroup, Input, Textarea, which a Form can group), charts (Chart), and animations (Transition). The Components page of the Widget Builder lists the properties of each, with an editor to try them out.
Designing the widget in the Widget Builder
The intended workflow starts in ChatKit Studio’s Widget Builder. You design the layout there, fill the data panel with sample values to judge the rendering, and then export a .widget file. This is a JSON file that combines the template, the schema of expected data (jsonSchema), and the sample data. Version-control it alongside your server code, for example in a widgets/ folder.
On the server side, WidgetTemplate loads the file and build fills it with real data:
from chatkit.widgets import WidgetTemplate
order_template = WidgetTemplate.from_file("widgets/order.widget")
card = order_template.build({
"number": "C-42",
"status": "Shipped",
"color": "success",
"items": [{"name": "Keyboard", "price": "$49.90"}, {"name": "Mouse", "price": "$10.00"}],
"total": "$59.90",
})
A relative path is resolved from the Python file calling from_file, not from the launch directory. build accepts a dictionary or a Pydantic model.
What a template contains
The template is a Jinja model that produces the widget’s JSON. The order card template looks like this:
ORDER_TEMPLATE = """
{
"type": "Card",
"children": [
{"type": "Row", "justify": "between", "align": "center", "children": [
{"type": "Title", "value": {{ ("Order " ~ number) | tojson }}, "size": "sm"},
{"type": "Badge", "label": {{ status | tojson }}, "color": {{ color | tojson }}}
]},
{"type": "Divider"},
{"type": "Col", "gap": 2, "children": [
{%- for item in items %}
{"type": "Row", "justify": "between", "children": [
{"type": "Text", "value": {{ item.name | tojson }}},
{"type": "Text", "value": {{ item.price | tojson }}}
]}{% if not loop.last %},{% endif %}
{%- endfor %}
]},
{"type": "Divider"},
{"type": "Row", "justify": "between", "children": [
{"type": "Text", "value": "Total", "weight": "semibold"},
{"type": "Text", "value": {{ total | tojson }}, "weight": "semibold"}
]}
]
}
"""
order_template = WidgetTemplate({"version": "1.0", "name": "order", "template": ORDER_TEMPLATE})
You will rarely write a template by hand, since the Widget Builder generates it. But reading one will save you time when the first problem arises. Three remarks:
- The
tojsonfilter converts each value to valid JSON (a quoted string, a boolean astrueorfalse) and escapes special characters: an apostrophe or quote in a product name won’t break the JSON. - Jinja runs in strict mode: missing data causes
buildto fail, instead of producing a half-empty widget. builddoes not validate data against thejsonSchema. This schema documents the data; if it comes from a tool or a database, validate it beforehand, for example with a Pydantic model.
The WidgetTemplate(...) constructor takes the same content as an already-read .widget file: this is handy for tests, or for a template generated by your own code.
Displaying the widget from respond
To send the widget into the thread, stream_widget produces the corresponding ChatKit events. You relay them from your respond method:
from chatkit.server import stream_widget
# In your ChatKitServer class
async def respond(self, thread, input_user_message, context):
card = order_template.build(await get_order_data("C-42")) # your data
async for event in stream_widget(
thread,
card,
copy_text="Order C-42: shipped, $59.90",
generate_id=lambda item_type: self.store.generate_item_id(item_type, thread, context),
):
yield event
copy_text is the text copied to the clipboard when the user clicks the copy button below the response. The server saves the widget to the store itself, like any broadcast element.
Letting the agent decide
Most often, it’s not your code that decides to display a card, but the agent, when the conversation calls for it. Give it a tool that broadcasts the widget with AgentContext.stream_widget: stream_agent_response, seen in lesson 17, transmits it to the interface in the middle of the response.
from agents import RunContextWrapper, function_tool
from chatkit.agents import AgentContext
@function_tool(description_override="Displays order details as a card.")
async def show_order(ctx: RunContextWrapper[AgentContext], number: str) -> str:
data = await get_order_data(number) # your data access
await ctx.context.stream_widget(order_template.build(data))
return f"The card for order {number} is displayed."
Add the tool to the agent’s tools list. The string returned by the tool serves the model, which thus knows the card is on screen and can comment on it instead of repeating it.
A clickable list
To offer a choice, ListView presents clickable rows. Each ListViewItem can carry an action, triggered on click without the user having to type anything; the next lesson shows how to handle it on the server side.
LIST_TEMPLATE = """
{
"type": "ListView",
"limit": 5,
"children": [
{%- for o in orders %}
{
"type": "ListViewItem",
"key": {{ o.number | tojson }},
"onClickAction": {"type": "order.open", "payload": {"number": {{ o.number | tojson }}}},
"children": [
{"type": "Col", "children": [
{"type": "Text", "value": {{ ("Order " ~ o.number) | tojson }},
{"type": "Caption", "value": {{ o.date | tojson }}}
]}
]
}{% if not loop.last %},{% endif %}
{%- endfor %}
]
}
"""
order_list_template = WidgetTemplate({"version": "1.0", "name": "order_list", "template": LIST_TEMPLATE})
list_widget = order_list_template.build({"orders": [{"number": "C-42", "date": "November 2"}, {"number": "C-41", "date": "October 28"}]})
Beyond the limit, ChatKit displays a “see more” link.
Updating a widget while displayed
A widget can evolve before the user’s eyes, for example to display a draft that the model is writing. Give stream_widget an asynchronous generator that produces successive states of the widget: ChatKit calculates the differences and sends only what changes.
from collections.abc import AsyncGenerator
from chatkit.widgets import DynamicWidgetRoot, WidgetTemplate
draft_template = WidgetTemplate({
"version": "1.0",
"name": "draft",
"template": """{"type": "Card", "children": [
{"type": "Title", "value": "Draft"},
{"type": "Text", "id": "body", "value": {{ text | tojson }}, "streaming": {{ in_progress | tojson }}}
]}""",
})
async def streaming_draft(chunks: AsyncGenerator[str, None]) -> AsyncGenerator[DynamicWidgetRoot, None]:
text = ""
async for chunk in chunks:
text += chunk
yield draft_template.build({"text": text, "in_progress": True})
# Final state, once the text is complete
yield draft_template.build({"text": text, "in_progress": False})
Only Text and Markdown components with an id display their updates in a stream, word by word; for others, ChatKit replaces the modified part of the widget.
What about the Python classes?
You will encounter, even in OpenAI guides, widgets built with classes: Card(children=[Text(value="…")]). They still work, but the openai-chatkit 1.6.5 SDK declares them deprecated in favor of WidgetTemplate: using them emits a warning pointing to .widget files. For a new project, start from templates. For an existing project, migrate at your own pace: the warning doesn’t block anything, but a deprecated class may disappear in a future version.
When to use a widget
The documentation cites typical uses:
- collecting structured input with a form, or presenting a rich result (table, card, chart) that text renders poorly;
- offering a choice between several options;
- combining the widget with actions, to validate a selection, confirm a step, or launch server-side processing;
- accompanying a short explanatory text with an interactive command.
Conversely, don’t put them everywhere. A widget that repeats word for word what the text says adds nothing, and a conversation saturated with cards becomes as difficult to read as a wall of text.
Key takeaways
- A ChatKit widget is a declarative description (JSON) built by your server, not a React component: ChatKit renders it itself
- Three containers:
Card(standalone content),ListView(list of options),Basic(entity previews) - Design in the Widget Builder, export a
.widgetfile, load it withWidgetTemplate.from_file(...), and fill it withbuild(data); theCard,Text… classes are deprecated - Broadcast with
stream_widgetfromrespond, or from an agent tool withctx.context.stream_widget - An asynchronous generator updates the widget live; only
TextandMarkdownwith anidstream word by word