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

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): Stores the widget in the session's extensionWidgets map
  3. Hook (useAgentSession.ts): Updates React state when requests arrive
  4. UI (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 (lines 166-172) and must include a unique widgetKey, content lines, and optionally a widgetPlacement.

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 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 (lines 75-86) listens for incoming UI requests and updates React state:

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 (lines 48-95) receives the widgets prop from 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:

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

Complete Working Example

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

// 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 defines the complete data structure:

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 Type definitions for widget data and UI requests 166-172
lib/rpc-manager.ts Server-side widget storage and session management 19-21
hooks/useAgentSession.ts React state management for widget updates 75-86
components/ExtensionWidgets.tsx Render logic, animations, and interaction 48-95
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 and rendering logic in 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 and modify the rendering logic in 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 with a broadcast clear command.

Does the widget content persist across page reloads?

No. Widgets live in server session memory via rpc-manager.ts and React state in useAgentSession.ts. A page reload reinitializes both, requiring your extension to re-register its widgets.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →