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
- Extension: Calls
agent.emit()with asetWidgetrequest - Server (
rpc-manager.ts): Stores the widget in the session'sextensionWidgetsmap - Hook (
useAgentSession.ts): Updates React state when requests arrive - 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 neededprivate 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
widgetKeywith newwidgetLinesreplaces the widget content - Create: New
widgetKeyadds a new widget to the array - Delete:
widgetLinesomitted 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
setWidgetUI requests from your extension usingagent.emit()withtype: "extension_ui_request" - Provide a unique
widgetKey, contentlinesarray, and optionalwidgetPlacement - Update existing widgets by re-emitting with the same
widgetKeyand new content - Remove widgets by omitting
widgetLinesor passing an empty array - Position widgets using
"aboveEditor"(default) or"belowEditor"placement - Reference the type definitions in
lib/types.tsand rendering logic incomponents/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:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →