Install any skill in seconds. Free to start, no credit card required.
Get Started Free →Build interactive AI chat widgets with buttons, forms, and bidirectional actions. Use when creating agentic UIs with clickable widgets, entity tagging (@mentions), composer tools, or server-handled widget actions. Covers full widget lifecycle. NOT when building simple text-only chat without interactive elements.
.claude/skills/aiskillstore-building-chat-widgets/SKILL.md| Test case | Without → With | Effect | Δ tokens | Δ turns |
|---|---|---|---|---|
| case-03 | ✗→✓ | ▲ Improved | 68% | 0% |
| case-08 | ✗→✓ | ▲ Improved | 95% | 0% |
| case-01 | ✗→✓ | ▲ Improved | 114% | 0% |
| case-02 | ✗→✓ | ▲ Improved | 29% | 0% |
| case-06 | ✗→✓ | ▲ Improved | 43% | 0% |
Create interactive widgets for AI chat with actions and entity tagging.
typescriptconst chatkit = useChatKit({ api: { url: API_URL, domainKey: DOMAIN_KEY }, widgets: { onAction: async (action, widgetItem) => { if (action.type === "view_details") { navigate(`/details/${action.payload.id}`); } }, }, });
| Handler | Defined In | Processed By | Use Case | |---------|------------|--------------|----------| | "client" | Widget template | Frontend onAction | Navigation, local state | | "server" | Widget template | Backend action() | Data mutation, widget replacement |
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 stateDefine reusable widget layouts with dynamic data:
json{ "type": "ListView", "children": [ { "type": "ListViewItem", "key": "item-1", "onClickAction": { "type": "item.select", "handler": "client", "payload": { "itemId": "item-1" } }, "children": [ { "type": "Row", "gap": 3, "children": [ { "type": "Icon", "name": "check", "color": "success" }, { "type": "Text", "value": "Item title", "weight": "semibold" } ] } ] } ] }
Actions that update local state, navigate, or send follow-up messages:
Widget Definition:
json{ "type": "Button", "label": "View Article", "onClickAction": { "type": "open_article", "handler": "client", "payload": { "id": "article-123" } } }
Frontend Handler:
typescriptconst chatkit = useChatKit({ api: { url: API_URL, domainKey: DOMAIN_KEY }, widgets: { onAction: async (action, widgetItem) => { 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; } }, }, });
Actions that mutate data, update widgets, or require backend processing:
Widget Definition:
json{ "type": "ListViewItem", "onClickAction": { "type": "line.select", "handler": "server", "payload": { "id": "blue-line" } } }
Backend Handler:
pythonfrom chatkit.types import ( Action, WidgetItem, ThreadItemReplacedEvent, ThreadItemDoneEvent, AssistantMessageItem, ClientEffectEvent, ) class MyServer(ChatKitServer[dict]): async def action( self, thread: ThreadMetadata, action: Action[str, Any], sender: WidgetItem | None, context: RequestContext, # Note: Already RequestContext, not dict ) -> AsyncIterator[ThreadStreamEvent]: if action.type == "line.select": line_id = action.payload["id"] # Use .payload, not .arguments # 1. Update widget with selection updated_widget = build_selector_widget(selected=line_id) yield ThreadItemReplacedEvent( item=sender.model_copy(update={"widget": updated_widget}) ) # 2. Stream assistant message 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}"}], ) ) # 3. Trigger client effect yield ClientEffectEvent( name="selection_changed", data={"lineId": line_id}, )
Allow users to @mention entities in messages:
typescriptconst 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}`); } }, }, });
Let users select different AI modes from the composer:
typescriptconst TOOL_CHOICES = [ { id: "general", label: "Chat", icon: "sparkle", placeholderOverride: "Ask anything...", pinned: true, }, { id: "event_finder", label: "Find Events", icon: "calendar", placeholderOverride: "What events are you looking for?", pinned: true, }, ]; const chatkit = useChatKit({ api: { url: API_URL, domainKey: DOMAIN_KEY }, composer: { placeholder: "What would you like to do?", tools: TOOL_CHOICES, }, });
Backend Routing:
pythonasync def respond(self, thread, item, context): tool_choice = context.metadata.get("tool_choice") if tool_choice == "event_finder": agent = self.event_finder_agent else: agent = self.general_agent result = Runner.run_streamed(agent, input_items) async for event in stream_agent_response(context, result): yield event
| Component | Props | Description | |-----------|-------|-------------| | ListView | children | Scrollable list container | | ListViewItem | key, onClickAction, children | Clickable list item | | Row | gap, align, justify, children | Horizontal flex | | Col | gap, padding, children | Vertical flex | | Box | size, radius, background, padding | Styled container |
| Component | Props | Description | |-----------|-------|-------------| | Text | value, size, weight, color | Text display | | Title | value, size, weight | Heading text | | Image | src, alt, width, height | Image display | | Icon | name, size, color | Icon from set |
| Component | Props | Description | |-----------|-------|-------------| | Button | label, variant, onClickAction | Clickable button |
IMPORTANT: Use action.payload, NOT action.arguments:
python# WRONG - Will cause AttributeError action.arguments # CORRECT action.payload
The context parameter is RequestContext, not dict:
python# WRONG - Tries to wrap RequestContext request_context = RequestContext(metadata=context) # CORRECT - Use directly user_id = context.user_id
When creating synthetic user messages:
pythonfrom chatkit.types import UserMessageItem, UserMessageTextContent # Include ALL required fields 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={}, )
action.payloadtype="input_text" for user messagesRun: python3 scripts/verify.py
Expected: ✓ building-chat-widgets skill ready
| Case | Status | Duration (ms) | Turns | Tokens | Tool calls | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Without | With | Δ | Without | With | Δ | Without | With | Δ | Without | With | Δ | ||
case-03 | fail→pass | 18,702 | 15,898 | -15% | 1 | 1 | 0% | 2,791 | 4,688 | +68% | 0 | 0 | — |
case-08 | fail→pass | 11,587 | 8,615 | -26% | 1 | 1 | 0% | 2,090 | 4,081 | +95% | 0 | 0 | — |
case-01 | fail→pass | 16,875 | 15,481 | -8% | 1 | 1 | 0% | 2,167 | 4,639 | +114% | 0 | 0 | — |
case-02 | fail→pass | 22,121 | 12,877 | -42% | 1 | 1 | 0% | 3,262 | 4,221 | +29% | 0 | 0 | — |
case-04 | pass→pass | 13,759 | 9,214 | -33% | 1 | 1 | 0% | 1,590 | 3,221 | +103% | 0 | 0 | — |
case-05 | pass→pass | 14,880 | 9,664 | -35% | 1 | 1 | 0% | 1,616 | 3,334 | +106% | 0 | 0 | — |
case-06 | fail→pass | 18,587 | 10,399 | -44% | 1 | 1 | 0% | 2,355 | 3,358 | +43% | 0 | 0 | — |
case-07 | pass→pass | 23,249 | 7,370 | -68% | 1 | 1 | 0% | 3,180 | 2,812 | -12% | 0 | 0 | — |
case-09 | pass→pass | 14,152 | 10,834 | -23% | 1 | 1 | 0% | 1,611 | 3,545 | +120% | 0 | 0 | — |
case-10 | fail→pass | 6,651 | 3,509 | -47% | 1 | 1 | 0% | 1,252 | 3,122 | +149% | 0 | 0 | — |
case-11 | fail→pass | 12,524 | 10,583 | -15% | 1 | 1 | 0% | 2,244 | 3,451 | +54% | 0 | 0 | — |
case-12 | fail→pass | 12,378 | 5,340 | -57% | 1 | 1 | 0% | 2,042 | 3,503 | +72% | 0 | 0 | — |
case-13 | fail→pass | 12,651 | 8,407 | -34% | 1 | 1 | 0% | 1,317 | 3,043 | +131% | 0 | 0 | — |
case-14 | fail→pass | 15,021 | 7,333 | -51% | 1 | 1 | 0% | 2,111 | 2,833 | +34% | 0 | 0 | — |
case-15 | fail→pass | 14,776 | 1,976 | -87% | 1 | 1 | 0% | 1,639 | 2,714 | +66% | 0 | 0 | — |
case-16 | fail→pass | 16,215 | 7,462 | -54% | 1 | 1 | 0% | 2,589 | 2,883 | +11% | 0 | 0 | — |
case-17 | pass→pass | 6,944 | 3,088 | -56% | 1 | 1 | 0% | 1,410 | 2,910 | +106% | 0 | 0 | — |
case-18 | fail→pass | 19,551 | 10,336 | -47% | 1 | 1 | 0% | 2,383 | 3,369 | +41% | 0 | 0 | — |
case-19 | pass→pass | 22,951 | 16,182 | -29% | 1 | 1 | 0% | 2,112 | 4,281 | +103% | 0 | 0 | — |
case-20 | fail→pass | 13,886 | 10,863 | -22% | 1 | 1 | 0% | 2,215 | 3,451 | +56% | 0 | 0 | — |
case-21 | pass→pass | 9,993 | 9,140 | -9% | 1 | 1 | 0% | 736 | 3,194 | +334% | 0 | 0 | — |
case-22 | fail→fail | 18,235 | 16,030 | -12% | 1 | 1 | 0% | 2,411 | 4,376 | +82% | 0 | 0 | — |
DecimalAI ran this skill against gemini-3.6-flash twice over the same eval suite — once with the skill loaded and once without — and compared the two runs case by case. 22 cases were attempted. The headline lift of +64 percentage points is the difference between those two pass rates over the 22 comparable cases.
Without the skill loaded, the model failed this case. With it loaded, the same prompt on the same model passed. This is one improved case from the latest verified run; every case, including any that regressed, is in the table above.
Other measured skills in the registry, with their headline benchmark lift.