home / skills / mjunaidca / mjs-agent-skills / building-chat-widgets

building-chat-widgets skill

/.claude/skills/building-chat-widgets

This skill helps you build interactive chat widgets with buttons, forms, and server-driven actions inside AI chat UIs.

npx playbooks add skill mjunaidca/mjs-agent-skills --skill building-chat-widgets

Review the files below or copy the command above to add this skill to your agents.

Files (4)
SKILL.md
8.9 KB
---
name: building-chat-widgets
description: |
  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.
---

# Building Chat Widgets

Create interactive widgets for AI chat with actions and entity tagging.

## Quick Start

```typescript
const chatkit = useChatKit({
  api: { url: API_URL, domainKey: DOMAIN_KEY },

  widgets: {
    onAction: async (action, widgetItem) => {
      if (action.type === "view_details") {
        navigate(`/details/${action.payload.id}`);
      }
    },
  },
});
```

---

## Action Handler Types

| Handler | Defined In | Processed By | Use Case |
|---------|------------|--------------|----------|
| `"client"` | Widget template | Frontend `onAction` | Navigation, local state |
| `"server"` | Widget template | Backend `action()` | 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
```

---

## Core Patterns

### 1. Widget Templates

Define 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" }
          ]
        }
      ]
    }
  ]
}
```

### 2. Client-Handled Actions

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:**

```typescript
const 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;
      }
    },
  },
});
```

### 3. Server-Handled Actions

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:**

```python
from 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},
            )
```

### 4. Entity Tagging (@mentions)

Allow users to @mention entities in messages:

```typescript
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}`);
      }
    },
  },
});
```

### 5. Composer Tools (Mode Selection)

Let users select different AI modes from the composer:

```typescript
const 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:**

```python
async 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
```

---

## 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`, `padding`, `children` | Vertical flex |
| `Box` | `size`, `radius`, `background`, `padding` | Styled container |

### Content Components

| 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 |

### Interactive Components

| Component | Props | Description |
|-----------|-------|-------------|
| `Button` | `label`, `variant`, `onClickAction` | Clickable button |

---

## Critical Implementation Details

### Action Object Structure

**IMPORTANT**: Use `action.payload`, NOT `action.arguments`:

```python
# WRONG - Will cause AttributeError
action.arguments

# CORRECT
action.payload
```

### Context Parameter

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
```

### UserMessageItem Required Fields

When creating synthetic user messages:

```python
from 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={},
)
```

---

## Anti-Patterns

1. **Mixing handlers** - Don't handle same action in both client and server
2. **Missing payload** - Always include data in action payload
3. **Using action.arguments** - Use `action.payload`
4. **Wrapping RequestContext** - Context is already RequestContext
5. **Missing UserMessageItem fields** - Include id, thread_id, created_at
6. **Wrong content type** - Use `type="input_text"` for user messages

---

## Verification

Run: `python3 scripts/verify.py`

Expected: `✓ building-chat-widgets skill ready`

## If Verification Fails

1. Check: references/ folder has widget-patterns.md
2. **Stop and report** if still failing

## References

- [references/widget-patterns.md](references/widget-patterns.md) - Complete widget patterns
- [references/server-action-handler.md](references/server-action-handler.md) - Backend action handling

Overview

This skill teaches how to build interactive AI chat widgets with buttons, forms, entity tagging (@mentions), composer tools, and both client- and server-handled actions. It covers the full widget lifecycle, common patterns, component reference, and critical implementation details for robust integrations. Use it to create agentic UIs where users click, tag, or trigger backend workflows from chat.

How this skill works

Widgets are produced by agent tools as WidgetItem objects and rendered in the chat UI. Actions on widgets dispatch events that are processed either by a frontend onAction callback (client handler) or by a backend action() method (server handler). Server handlers can replace widgets, stream assistant messages, and emit client effects. Entity tagging and composer tools let users mention entities and select AI modes that influence agent routing.

When to use it

  • When you need clickable UI elements inside chat (buttons, list items, selectors).
  • When actions must trigger backend processing, data mutation, or widget replacement.
  • When you want entity tagging (@mentions) that resolves to interactive search results.
  • When providing composer tools or mode selection that change agent behavior.
  • Not for simple text-only chat flows without interactive elements.

Best practices

  • Choose a single handler per action: client OR server, never both.
  • Always include meaningful data in action.payload (do not use action.arguments).
  • Treat context as a RequestContext object — don’t rewrap it. Access context.user_id and metadata directly.
  • When emitting synthetic user messages, include all required fields (id, thread_id, created_at, content with type="input_text").
  • Replace or update widgets after actions to reflect state; stream assistant messages for follow-up UX.

Example use cases

  • Clickable ListView of search results where client actions navigate to detail pages.
  • Server-handled selection that updates a selector widget, streams a confirmation assistant message, and triggers a client effect.
  • Entity tagging for people or articles that resolves via onTagSearch and navigates on entity click.
  • Composer tool selection (e.g., Chat vs. Event Finder) that routes the thread to different agents on the backend.
  • Buttons that request more suggestions by sending follow-up user messages from the frontend.

FAQ

How do I decide client vs server handler?

Use client handlers for local UI effects, navigation, or lightweight follow-up messages. Use server handlers for data mutation, widget replacement, streaming assistant content, or triggering downstream workflows.

What causes AttributeError with action objects?

Using action.arguments instead of action.payload will cause errors. Always read action.payload for payload data.

How should I create a synthetic user message on the server?

Construct a UserMessageItem with id, thread_id, created_at, and content that includes UserMessageTextContent of type="input_text"; include inference_options if required.