| name | chatkit-actions |
| description | Implements interactive widget actions and bidirectional communication patterns for ChatKit. This skill should be used when building AI-driven interactive UIs with buttons, forms, entity tagging (@mentions), composer tools, and server-handled widget actions. Covers the full widget lifecycle from creation to replacement. |
ChatKit Actions Skill
Overview
This skill unlocks the full power of ChatKit's agentic UI capabilities - where AI can render interactive widgets, users can click buttons that trigger both client and server actions, and the conversation becomes a two-way interactive experience.
Core Concepts
Action Handler Types
Widgets can specify where actions are handled:
| Handler | Defined In | Processed By | Use Case |
|---|
"client" | Widget template | Frontend onAction | Navigation, local state, send follow-up |
"server" | Widget template | Backend action() method | Data mutation, widget replacement |
Widget Lifecycle
1. Agent tool generates widget → yield WidgetItem
2. Widget renders in chat with action buttons
3. User clicks action → action dispatched
4. Handler processes action:
- client: onAction callback in frontend
- server: action() method in ChatKitServer
5. Optional: Widget replaced with updated state
Implementation Patterns
Pattern 1: Widget Templates (.widget files)
When: Define reusable widget layouts with dynamic data
Widget Template Format:
{
"version": "1.0",
"name": "task_list",
"template": "{\"type\":\"ListView\",\"children\":[...jinja template...]}",
"jsonSchema": {
"type": "object",
"properties": {
"tasks": { "type": "array", "items": {...} }
}
}
}
Widget Components Available:
- Layout:
ListView, ListViewItem, Row, Col, Box
- Content:
Text, Title, Image, Icon
- Interactive:
Button (with onClickAction)
- Styling:
gap, padding, background, border, radius
Example - Task List Widget:
{
"type": "ListView",
"children": [
{
"type": "ListViewItem",
"key": "task-1",
"onClickAction": {
"type": "task.select",
"handler": "client",
"payload": { "taskId": "task-1" }
},
"children": [
{
"type": "Row",
"gap": 3,
"children": [
{ "type": "Icon", "name": "check", "color": "success" },
{ "type": "Text", "value": "Complete review", "weight": "semibold" }
]
}
]
}
]
}
Python - Loading Templates:
from chatkit.widgets import WidgetTemplate, WidgetRoot
task_list_template = WidgetTemplate.from_file("task_list.widget")
def build_task_list_widget(tasks: list[Task]) -> WidgetRoot:
return task_list_template.build(
data={
"tasks": [task.model_dump() for task in tasks],
"selected": None,
}
)
Evidence: cat-lounge/backend/app/widgets/cat_name_suggestions.widget
Pattern 2: Client-Handled Actions
When: Actions that update local state, navigate, or send follow-up messages
Widget Definition (handler: "client"):
{
"type": "Button",
"label": "View Article",
"onClickAction": {
"type": "open_article",
"handler": "client",
"payload": { "id": "article-123" }
}
}
Frontend Handler:
import { useChatKit, type Widgets } from "@openai/chatkit-react";
const chatkit = useChatKit({
api: { url: API_URL, domainKey: DOMAIN_KEY },
widgets: {
onAction: async (
action: { type: string; payload?: Record<string, unknown> },
widgetItem: { id: string; widget: Widgets.Card | Widgets.ListView }
) => {
switch (action.type) {
case "open_article":
navigate(`/article/${action.payload?.id}`);
break;
case "more_suggestions":
await chatkit.sendUserMessage({ text: "More suggestions, please" });
break;
case "select_option":
setSelectedOption(action.payload?.optionId);
break;
}
},
},
});
Evidence: news-guide/frontend/src/components/ChatKitPanel.tsx:55-79
Pattern 3: Server-Handled Actions
When: Actions that mutate data, update widgets, or require backend processing
Widget Definition (handler: "server"):
{
"type": "ListViewItem",
"onClickAction": {
"type": "line.select",
"handler": "server",
"payload": { "id": "blue-line" }
}
}
Backend Handler:
from chatkit.server import ChatKitServer
from chatkit.types import (
Action,
WidgetItem,
ThreadItemReplacedEvent,
ThreadItemDoneEvent,
AssistantMessageItem,
HiddenContextItem,
ClientEffectEvent,
)
class MyServer(ChatKitServer[dict]):
async def action(
self,
thread: ThreadMetadata,
action: Action[str, Any],
sender: WidgetItem | None,
context: dict[str, Any],
) -> AsyncIterator[ThreadStreamEvent]:
if action.type == "line.select":
line_id = action.payload["id"]
updated_widget = build_line_selector_widget(
lines=self.lines,
selected=line_id,
)
yield ThreadItemReplacedEvent(
item=sender.model_copy(update={"widget": updated_widget})
)
await self.store.add_thread_item(
thread.id,
HiddenContextItem(
id=self.store.generate_item_id("ctx", thread, context),
thread_id=thread.id,
created_at=datetime.now(),
content=f"<LINE_SELECTED>{line_id}</LINE_SELECTED>",
),
context=context,
)
yield ThreadItemDoneEvent(
item=AssistantMessageItem(
id=self.store.generate_item_id("msg", thread, context),
thread_id=thread.id,
created_at=datetime.now(),
content=[{"text": f"Selected {line_id}. Where to add station?"}],
)
)
yield ClientEffectEvent(
name="location_select_mode",
data={"lineId": line_id},
)
Evidence: metro-map/backend/app/server.py
Pattern 4: Client-to-Server Action Forwarding
When: Client handles action locally, then notifies server for persistence/widget update
Frontend - Send Custom Action:
widgets: {
onAction: async (action, widgetItem) => {
if (action.type === "select_name") {
await chatkit.sendCustomAction(action, widgetItem.id);
const data = await refreshCatStatus();
if (data) {
handleStatusUpdate(data, `Now called ${data.name}`);
}
}
},
}
Evidence: cat-lounge/frontend/src/components/ChatKitPanel.tsx:68-80
Pattern 5: Entity Tagging (@mentions)
When: Allow users to @mention entities (users, articles, tasks) in messages
Frontend - Entity Configuration:
import { useChatKit, type Entity } from "@openai/chatkit-react";
const chatkit = useChatKit({
api: { url: API_URL, domainKey: DOMAIN_KEY },
entities: {
onTagSearch: async (query: string): Promise<Entity[]> => {
const results = await fetch(`/api/search?q=${query}`).then(r => r.json());
return results.map((item) => ({
id: item.id,
title: item.name,
icon: item.type === "person" ? "profile" : "document",
group: item.type === "People" ? "People" : "Articles",
interactive: true,
data: {
type: item.type,
article_id: item.id,
},
}));
},
onClick: (entity: Entity) => {
if (entity.data?.article_id) {
navigate(`/article/${entity.data.article_id}`);
}
},
onRequestPreview: async (entity: Entity) => {
const details = await fetch(`/api/entity/${entity.id}`).then(r => r.json());
return {
preview: {
type: "Card",
children: [
{ type: "Text", value: entity.title, weight: "bold" },
{ type: "Text", value: details.description, color: "tertiary" },
],
},
};
},
},
});
Backend - Converting Entity Tags:
class EntityAwareConverter(BasicThreadItemConverter):
"""Convert entity tags to model-readable markers."""
async def to_agent_input(self, items: list[ThreadItem]) -> list:
result = []
for item in items:
if isinstance(item, UserMessageItem):
content = item.content
for entity in item.entities or []:
if entity.type == "article":
content = content.replace(
f"@{entity.title}",
f"<ARTICLE_REFERENCE id='{entity.id}'>{entity.title}</ARTICLE_REFERENCE>"
)
result.append({"role": "user", "content": content})
return result
Evidence:
news-guide/frontend/src/components/ChatKitPanel.tsx:122-126
news-guide/backend/app/thread_item_converter.py
metro-map/frontend/src/components/ChatKitPanel.tsx:73-117
Pattern 6: Composer Tools (Mode Selection)
When: Let users select different AI modes/tools from the composer
Frontend - Tool Configuration:
const TOOL_CHOICES = [
{
id: "general",
label: "Chat",
shortLabel: "Chat",
icon: "sparkle",
placeholderOverride: "Ask anything...",
pinned: true,
},
{
id: "event_finder",
label: "Find Events",
shortLabel: "Events",
icon: "calendar",
placeholderOverride: "What events are you looking for?",
pinned: true,
},
{
id: "puzzle",
label: "Word Puzzle",
shortLabel: "Puzzle",
icon: "bolt",
placeholderOverride: "Ready for today's puzzle?",
pinned: false,
},
];
const chatkit = useChatKit({
api: { url: API_URL, domainKey: DOMAIN_KEY },
composer: {
placeholder: "What would you like to do?",
tools: TOOL_CHOICES,
},
});
Backend - Routing by Tool Choice:
async def respond(self, thread, item, context):
tool_choice = context.get("tool_choice")
if tool_choice == "event_finder":
agent = self.event_finder_agent
elif tool_choice == "puzzle":
agent = self.puzzle_agent
else:
agent = self.general_agent
result = Runner.run_streamed(agent, input_items, context=agent_context)
async for event in stream_agent_response(agent_context, result):
yield event
Evidence: news-guide/frontend/src/lib/config.ts
Pattern 7: Thread Item Actions (Feedback/Retry/Share)
When: Enable built-in actions on AI messages
Frontend Configuration:
const chatkit = useChatKit({
api: { url: API_URL, domainKey: DOMAIN_KEY },
threadItemActions: {
feedback: true,
retry: true,
share: true,
},
onLog: ({ name, data }) => {
if (name === "message.feedback") {
fetch("/api/analytics/feedback", {
method: "POST",
body: JSON.stringify(data),
});
}
if (name === "message.share") {
fetch("/api/analytics/share", {
method: "POST",
body: JSON.stringify(data),
});
}
},
});
Pattern 8: Widget Streaming from Tools
When: Agent tool generates a widget as part of response
Backend - Tool with Widget Output:
from chatkit.types import WidgetItem
from agents import function_tool
@function_tool
async def show_article_list(ctx: AgentContext, query: str) -> str:
"""Show a list of articles matching the query."""
articles = await article_store.search(query)
widget = build_article_list_widget(articles)
widget_item = WidgetItem(
id=ctx.store.generate_item_id("widget", ctx.thread, ctx.request_context),
thread_id=ctx.thread.id,
created_at=datetime.now(),
widget=widget,
)
await ctx.store.add_thread_item(ctx.thread.id, widget_item, ctx.request_context)
yield ThreadItemDoneEvent(item=widget_item)
return f"Showing {len(articles)} articles"
Evidence: news-guide/backend/app/agents/news_agent.py
Widget Component Reference
Layout Components
| Component | Props | Description |
|---|
ListView | children | Scrollable list container |
ListViewItem | key, onClickAction, children | Clickable list item |
Row | gap, align, justify, children | Horizontal flex |
Col | gap, align, justify, flex, padding, children | Vertical flex |
Box | size, radius, background, border, padding | Container with styling |
Content Components
| Component | Props | Description |
|---|
Text | value, size, weight, color, maxLines | Text display |
Title | value, size, weight | Heading text |
Image | src, alt, width, height, fit, radius | Image display |
Icon | name, size, color | Icon from icon set |
Interactive Components
| Component | Props | Description |
|---|
Button | label, variant, color, size, pill, block, iconStart, iconEnd, onClickAction, disabled | Clickable button |
Action Structure
interface Action {
type: string;
handler: "client" | "server";
payload?: Record<string, unknown>;
}
Common Patterns Summary
| Pattern | Frontend | Backend | Use Case |
|---|
| Navigation | onAction → navigate | - | Open details page |
| Follow-up | onAction → sendUserMessage | - | "More suggestions" |
| Selection | sendCustomAction | action() → ThreadItemReplacedEvent | Select from list |
| Data mutation | sendCustomAction | action() → update DB | Approve/reject |
| @mentions | entities.onTagSearch | ThreadItemConverter | Reference entities |
| Mode switch | composer.tools | Route by tool_choice | Different agents |
Critical Implementation Details
Action Object Structure
IMPORTANT: The Action object uses payload, NOT arguments:
action.arguments
action.payload
Action Type Definition:
from chatkit.types import Action
action.type
action.payload
action.handler
Server Action Handler Signature
CRITICAL: The context parameter is RequestContext, NOT dict[str, Any]
async def action(
self,
thread: ThreadMetadata,
action: Action[str, Any],
sender: WidgetItem | None,
context: dict[str, Any],
) -> AsyncIterator[ThreadStreamEvent]:
request_context = RequestContext(metadata=context)
user_id = context.user_id
metadata = context.metadata
Why this happens: ChatKit SDK passes RequestContext object at runtime, despite type annotations suggesting dict. Always use context directly without wrapping.
UserMessageItem Required Fields
When creating synthetic user messages from actions, ALL these fields are required:
from chatkit.types import UserMessageItem, UserMessageTextContent
from datetime import datetime
synthetic_message = UserMessageItem(
content=[UserMessageTextContent(type="text", text=message_text)]
)
synthetic_message = UserMessageItem(
id=self.store.generate_item_id("message", thread, context),
thread_id=thread.id,
created_at=datetime.now(),
content=[UserMessageTextContent(type="input_text", text=message_text)],
inference_options={},
)
Required fields:
id: Generate via store.generate_item_id("message", thread, context)
thread_id: From thread.id parameter
created_at: Current timestamp via datetime.now()
content: List of content blocks (UserMessageTextContent)
inference_options: Empty dict {} if no special options
UserMessageTextContent type values:
- ✅
type="input_text" - User text input (correct)
- ❌
type="text" - Invalid for UserMessageTextContent (causes ValidationError)
Local Tool Wrappers for Widget Streaming
Problem: Agent calls MCP tool successfully, but widget doesn't appear in UI.
Root Cause: Widgets stream via RunHooks pattern. MCP tools alone don't trigger widget rendering - you need local tool wrappers.
Solution Pattern:
from agents import function_tool
@function_tool
async def show_task_form(
ctx: RunContextWrapper[TaskFlowAgentContext],
) -> str:
"""Show interactive task creation form widget."""
agent_ctx = ctx.context
mcp_url = agent_ctx.mcp_server_url
result = await _call_mcp_tool(
mcp_url,
"taskflow_show_task_form",
arguments={"params": {"user_id": agent_ctx.user_id}},
access_token=agent_ctx.access_token,
)
return json.dumps(result)
agent = Agent(
name="TaskFlow Assistant",
tools=[
show_task_form,
],
)
async def on_tool_end(self, output: str | None, tool_name: str) -> None:
if tool_name == "show_task_form":
result = json.loads(output)
if result.get("action") == "show_form":
widget = build_task_form_widget()
yield WidgetItem(...)
Key insight: Direct MCP tools → no widgets. Local wrappers → RunHooks → widgets streamed.
Common Pydantic Validation Errors
Error 1: 'Action' object has no attribute 'arguments'
AttributeError: 'Action[str, Any]' object has no attribute 'arguments'
Fix: Use action.payload instead of action.arguments
Error 2: UserMessageTextContent type mismatch
ValidationError: Input should be 'input_text' [type=literal_error, input_value='text']
Fix: Use type="input_text" for user input, not type="text"
Error 3: UserMessageItem missing required fields
4 validation errors for UserMessageItem
- id: Field required
- thread_id: Field required
- created_at: Field required
- inference_options: Field required
Fix: Include all required fields when creating UserMessageItem (see pattern above)
Error 4: RequestContext wrapping issue
2 validation errors for RequestContext
user_id: Field required
metadata: Input should be a valid dictionary [input_value=RequestContext(...)]
Fix: Don't wrap context - it's already a RequestContext object
Widget Action Testing Checklist
Before claiming widget actions are complete, test:
Anti-Patterns to Avoid
- Mixing handlers - Don't handle same action in both client and server
- Missing payload - Always include necessary data in action payload
- Forgetting widget ID -
sendCustomAction requires widget ID for updates
- Not updating widget - Server actions should yield
ThreadItemReplacedEvent
- Blocking in onAction - Keep client handlers fast, offload to server
- Using action.arguments - Use
action.payload (arguments doesn't exist)
- Wrapping RequestContext - Context is already RequestContext, don't wrap it
- Missing UserMessageItem fields - Include id, thread_id, created_at, inference_options
- Wrong content type - Use
type="input_text" for user messages
- No local tool wrappers - MCP tools alone don't stream widgets
- Not testing thoroughly - Test all actions with real data before claiming done
- Assuming type hints are correct - ChatKit has type annotation vs runtime mismatches
References
Documentation
references/widget-templates.md - Widget template syntax
references/client-vs-server-actions.md - Action routing guide
references/entity-tagging.md - @mention implementation
references/composer-tools.md - Tool choice patterns
references/server-action-handler.py - Complete backend action handler pattern
Widget Template Assets
assets/line-select.widget - Server action selection list (metro-map pattern)
assets/name-suggestions.widget - Client action with "more" button (cat-lounge pattern)
assets/article-list.widget - Rich card layout with images (news-guide pattern)
Evidence Sources
All patterns derived from OpenAI ChatKit advanced samples:
blueprints/openai-chatkit-advanced-samples-main/examples/cat-lounge/
blueprints/openai-chatkit-advanced-samples-main/examples/metro-map/
blueprints/openai-chatkit-advanced-samples-main/examples/news-guide/