# How to Add Custom Extension Widgets to Pi Web's Chat Interface: A Complete Guide

> Learn to add custom extension widgets to Pi Web's chat interface. This guide shows you how to emit a setWidget UI request using agent.emit() for seamless integration.

- Repository: [Alex Yang/pi-web](https://github.com/agegr/pi-web)
- Tags: how-to-guide
- Published: 2026-08-14

---

**Emit a `setWidget` UI request from your Pi extension using `agent.emit()`, which the server stores in `extensionWidgets` state and the `ExtensionWidgets` React component renders in the chat UI.**

The **agegr/pi-web** repository provides a built-in extension widget system that lets you add custom UI panels to the chat interface. These widgets appear as expandable, live-updating content blocks positioned either above or below the editor area. This guide walks through the complete data flow—from extension code to rendered UI—using the actual implementation in Pi Web's source.

## Understanding the Extension Widget Architecture

Pi Web's widget system involves four coordinated layers: the extension emitting requests, the server managing state, the React hook updating the UI, and the component rendering the final output. Here's how they connect.

### The Four-Layer Data Flow

1. **Extension**: Calls `agent.emit()` with a `setWidget` request
2. **Server ([`rpc-manager.ts`](https://github.com/agegr/pi-web/blob/main/rpc-manager.ts))**: Stores the widget in the session's `extensionWidgets` map
3. **Hook ([`useAgentSession.ts`](https://github.com/agegr/pi-web/blob/main/useAgentSession.ts))**: Updates React state when requests arrive
4. **UI ([`ExtensionWidgets.tsx`](https://github.com/agegr/pi-web/blob/main/ExtensionWidgets.tsx))**: Renders expandable widgets with placement and animation support

## Step 1: Emit a setWidget Request from Your Extension

Start by importing the Pi SDK and emitting a `setWidget` UI request. The request format is defined in [`lib/types.ts`](https://github.com/agegr/pi-web/blob/main/lib/types.ts) (lines 166-172) and must include a unique `widgetKey`, content `lines`, and optionally a `widgetPlacement`.

```ts
import { Agent } from "@pi/agent";   // pi-web already bundles the Pi SDK

export async function myExtension(agent: Agent) {
  await agent.emit({
    type: "extension_ui_request",
    id: "my-widget-1",
    method: "setWidget",
    widgetKey: "myWidget",
    widgetLines: [
      "🛠️ Custom widget content line 1",
      "🛠️ Custom widget content line 2",
    ],
    widgetPlacement: "belowEditor",  // optional: "aboveEditor" (default) or "belowEditor"
  });
}

```

The `widgetKey` must be unique per widget. Reusing the same key with new content updates the existing widget rather than creating a duplicate.

## Step 2: Server-Side Widget Storage in rpc-manager.ts

When the request arrives, [`lib/rpc-manager.ts`](https://github.com/agegr/pi-web/blob/main/lib/rpc-manager.ts) handles the `extensionWidgets` map at line 19. The manager provides:

- `emitExtensionWidgetClear()`: Clears widgets when needed
- `private getExtensionWidgets()`: Retrieves the current widget array (lines 19-21)
- Internal storage for the `ExtensionWidgetItem[]` array

The runtime doesn't persist widgets to a database—they live in the server session memory and synchronize to connected clients via the hook.

## Step 3: State Updates via useAgentSession.ts

The `useAgentSession` hook in [`hooks/useAgentSession.ts`](https://github.com/agegr/pi-web/blob/main/hooks/useAgentSession.ts) (lines 75-86) listens for incoming UI requests and updates React state:

```ts
case "setWidget":
  setExtensionWidgets((prev) => {
    const rest = prev.filter((item) => item.key !== request.widgetKey);
    return request.widgetLines
      ? [...rest, {
          key: request.widgetKey,
          lines: request.widgetLines,
          placement: request.widgetPlacement ?? "aboveEditor",
        }]
      : rest;  // empty widgetLines removes the widget
  });
  break;

```

Key behaviors:
- **Update**: Same `widgetKey` with new `widgetLines` replaces the widget content
- **Create**: New `widgetKey` adds a new widget to the array
- **Delete**: `widgetLines` omitted or empty removes the widget

The default `placement` is `"aboveEditor"` when not specified.

## Step 4: Rendering Widgets with ExtensionWidgets.tsx

The `ExtensionWidgets` component in [`components/ExtensionWidgets.tsx`](https://github.com/agegr/pi-web/blob/main/components/ExtensionWidgets.tsx) (lines 48-95) receives the `widgets` prop from [`ChatWindow.tsx`](https://github.com/agegr/pi-web/blob/main/ChatWindow.tsx) and handles:

| Feature | Implementation |
|---------|---------------|
| **Content snapshots** | `snapshotExtensionWidgetContents` for "pulse" detection |
| **Live update animation** | `getUpdatedExtensionWidgetKeys` triggers CSS pulse on changes |
| **Expand/collapse** | `expandedWidgetKey` state with `toggleWidget` handler |
| **Placement positioning** | Separates widgets into "aboveEditor" and "belowEditor" groups |

The component renders in [`ChatWindow.tsx`](https://github.com/agegr/pi-web/blob/main/ChatWindow.tsx):

```tsx
{extensionWidgets.length > 0 && <ExtensionWidgets widgets={extensionWidgets} />}

```

## Complete Working Example

Here's a minimal extension that registers a widget and updates it periodically:

```ts
// status-widget-extension.ts
import { Agent } from "@pi/agent";

export async function register(agent: Agent) {
  let counter = 0;
  
  // Initial widget registration
  await updateWidget(agent, counter);
  
  // Live updates every 3 seconds
  setInterval(() => {
    counter++;
    updateWidget(agent, counter);
  }, 3000);
}

async function updateWidget(agent: Agent, count: number) {
  await agent.emit({
    type: "extension_ui_request",
    id: `status-update-${Date.now()}`,
    method: "setWidget",
    widgetKey: "liveStatus",
    widgetLines: [
      `⏱️ Uptime: ${count * 3} seconds`,
      `📊 Active connections: ${Math.floor(Math.random() * 10)}`,
    ],
    widgetPlacement: "belowEditor",
  });
}

```

Load this extension through Pi Web's **Plugins** or **Skills** UI. The widget appears below the editor with a pulsing indicator on each update.

## Widget Type Reference

The `ExtensionWidgetItem` interface in [`lib/types.ts`](https://github.com/agegr/pi-web/blob/main/lib/types.ts) defines the complete data structure:

```ts
export interface ExtensionWidgetItem {
  key: string;                           // unique widget identifier
  lines: string[];                       // content lines to display
  placement: "aboveEditor" | "belowEditor";  // vertical position
}

```

## Key Source Files and Their Roles

| File | Purpose | Lines of Interest |
|------|---------|-------------------|
| [`lib/types.ts`](https://github.com/agegr/pi-web/blob/main/lib/types.ts) | Type definitions for widget data and UI requests | 166-172 |
| [`lib/rpc-manager.ts`](https://github.com/agegr/pi-web/blob/main/lib/rpc-manager.ts) | Server-side widget storage and session management | 19-21 |
| [`hooks/useAgentSession.ts`](https://github.com/agegr/pi-web/blob/main/hooks/useAgentSession.ts) | React state management for widget updates | 75-86 |
| [`components/ExtensionWidgets.tsx`](https://github.com/agegr/pi-web/blob/main/components/ExtensionWidgets.tsx) | Render logic, animations, and interaction | 48-95 |
| [`components/ChatWindow.tsx`](https://github.com/agegr/pi-web/blob/main/components/ChatWindow.tsx) | Parent component integrating widgets into chat | widget prop injection |

## Common Patterns for Custom Extension Widgets

### Pattern 1: Status Dashboard

Display real-time metrics from your extension's background processes.

### Pattern 2: Log Output Stream

Append log lines to a `widgetLines` array and trim to last N entries for a scrolling log view.

### Pattern 3: Interactive Confirmation Panel

Use widget content to show pending actions, then listen for user input events to proceed.

### Pattern 4: Collapsible Detail Views

Leverage the built-in expand/collapse behavior to hide verbose output until needed.

## Summary

- **Emit** `setWidget` UI requests from your extension using `agent.emit()` with `type: "extension_ui_request"`
- **Provide** a unique `widgetKey`, content `lines` array, and optional `widgetPlacement`
- **Update** existing widgets by re-emitting with the same `widgetKey` and new content
- **Remove** widgets by omitting `widgetLines` or passing an empty array
- **Position** widgets using `"aboveEditor"` (default) or `"belowEditor"` placement
- **Reference** the type definitions in [`lib/types.ts`](https://github.com/agegr/pi-web/blob/main/lib/types.ts) and rendering logic in [`components/ExtensionWidgets.tsx`](https://github.com/agegr/pi-web/blob/main/components/ExtensionWidgets.tsx)

## Frequently Asked Questions

### What happens if two extensions use the same widgetKey?

The last `setWidget` request wins. Widgets with duplicate keys overwrite each other in the `extensionWidgets` array. Use namespaced keys like `"myExtension/featureName"` to avoid collisions.

### Can widgets include interactive elements like buttons?

The current `ExtensionWidgetItem` interface only supports text `lines`. For interactive elements, you would need to extend the type definition in [`lib/types.ts`](https://github.com/agegr/pi-web/blob/main/lib/types.ts) and modify the rendering logic in [`components/ExtensionWidgets.tsx`](https://github.com/agegr/pi-web/blob/main/components/ExtensionWidgets.tsx) to handle additional properties.

### How do I clear all widgets from my extension?

Emit `setWidget` requests with empty `widgetLines` for each key you registered, or implement a session-wide clear mechanism by extending [`rpc-manager.ts`](https://github.com/agegr/pi-web/blob/main/rpc-manager.ts) with a broadcast clear command.

### Does the widget content persist across page reloads?

No. Widgets live in server session memory via [`rpc-manager.ts`](https://github.com/agegr/pi-web/blob/main/rpc-manager.ts) and React state in [`useAgentSession.ts`](https://github.com/agegr/pi-web/blob/main/useAgentSession.ts). A page reload reinitializes both, requiring your extension to re-register its widgets.