Widget Actions
Widgets are sandboxed iframes that give your plugin a visual UI. Widget actions let the user interact with your plugin directly from the widget — clicking buttons, submitting forms, approving workflows — without typing in chat.
Widgets vs Public Apps
Widgets are not the same as public pages:
- Widgets run inside the authenticated PIE app and use the injected
PIESDK. - Public apps are normal browser pages served at
/apps/{pluginSlug}/{instanceSlug}/...(or a custom domain). - Widget actions use
PIE.sendAction()andcontext.widget. - Public pages use browser
fetch()to call/api/public-actions/{instanceId}/{actionId}and handle responses throughcontext.publicApp.
If you need a shareable URL for anonymous visitors, use the Public Apps and Routes guide. A common pattern is: widget for the builder/admin UI, public app for the visitor-facing experience.
How Widget Actions Work
Widget iframe Server Plugin handler
───────────── ────── ──────────────
PIE.sendAction(id, payload) ──► POST /widget-action ──► executeWidgetAction (isolated-vm)
│
PIE.onData(callback) ◄──────── widgetEvents ◄────────── context.widget.update()2
3
4
5
- Your widget JS calls
PIE.sendAction('my_action', { key: 'value' }) - The platform runs your plugin handler in isolated-vm (fast, 30s timeout, 64MB memory)
- Your handler receives
{ _widgetAction: true, actionId: 'my_action', payload: { key: 'value' } } - Your handler does lightweight work (DB queries, widget updates) and returns a result
- Any
context.widget.update()calls are collected and sent back to the iframe asPIE.onDataevents
Widget HTML Setup
Your widget HTML must include the PIE SDK stub and register a data handler:
<script>
// PIE SDK is injected by the platform
PIE.onData(function(data) {
// Handle events from your plugin handler
if (data.event === 'items_loaded') {
renderItems(data.items);
}
});
// Send an action to your plugin handler
function onButtonClick() {
PIE.sendAction('load_items', { page: 1 });
}
</script>2
3
4
5
6
7
8
9
10
11
12
13
14
Handler Routing
In your plugin handler, check for input._widgetAction to route widget actions:
async function handler(input, context) {
if (input._widgetAction) {
switch (input.actionId) {
case 'load_items': {
var items = await context.managedDb.query('SELECT * FROM items LIMIT 20');
await context.widget.update({ event: 'items_loaded', items: items.rows });
return { success: true };
}
default:
return { error: true, message: 'Unknown action' };
}
}
// Normal tool actions (called by the LLM)
switch (input.action) {
case 'create_item': { /* ... */ }
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
Exposing Widget Actions as Heartbeat Events
If a widget action represents a real business outcome, add it to manifest.heartbeatEvents so users can subscribe to it from Heartbeats.
{
"heartbeatEvents": {
"events": [
{
"id": "item_published",
"displayName": "Item published",
"description": "Fires when a user publishes an item from the widget.",
"enabled": true,
"matchers": [
{ "source": "widget", "actionId": "publish_item" }
]
}
]
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
Use widget matchers for lightweight actions that mutate state directly inside the widget runtime. If the widget action returns _triggerToolCall, prefer matching the background tool action instead so the same event does not fire twice.
Triggering Background Work (_triggerToolCall)
Widget actions run in isolated-vm with a 30-second timeout. This is fast but too short for heavy operations like image generation, large API calls, or data processing.
The _triggerToolCall pattern solves this: your widget action does lightweight setup, then returns a trigger object. The platform dispatches a background E2B execution of your plugin with the trigger as input — no LLM, no timeout pressure.
The Pattern
case 'approve_and_generate': {
// 1. Lightweight DB update (runs in isolated-vm, fast)
await context.managedDb.query(
'UPDATE jobs SET status=$1 WHERE id=$2', ['processing', payload.jobId]
);
// 2. Immediately update the widget so the user sees feedback
await context.widget.update({
event: 'processing_started',
jobId: payload.jobId,
flash: 'Starting generation...'
});
// 3. Return a trigger to dispatch heavy work in E2B
return {
success: true,
_triggerToolCall: {
action: 'generate_output',
job_id: payload.jobId
}
};
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
What Happens
- Isolated-vm (fast): DB update + widget update + return trigger → response sent to user immediately
- E2B (background): Platform calls
toolSandbox.execute(pluginCode, { action: 'generate_output', job_id: '...' })→ your handler'sswitch(input.action)routes to the heavy action - Widget update (when E2B finishes): Your E2B handler calls
context.widget.update()→ pushed to the widget via SSE
_triggerToolCall Format
The _triggerToolCall value must be an object with an action string. Additional properties are passed through:
{
_triggerToolCall: {
action: 'my_heavy_action', // Required: matches your handler's switch case
item_id: 'abc-123', // Optional: any params your action needs
options: { quality: 'high' } // Optional: nested objects work too
}
}2
3
4
5
6
7
The object is passed directly as input to your handler in E2B, so input.action will be 'my_heavy_action', input.item_id will be 'abc-123', etc.
Example: Slide Deck Edit Flow
// Widget action (isolated-vm, ~2s)
case 'apply_edits': {
await context.managedDb.query(
'UPDATE slides SET status=$1 WHERE id=$2', ['regenerating', payload.slideId]
);
await context.widget.update({
event: 'slide_regenerating',
slideId: payload.slideId,
flash: 'Regenerating slide...'
});
return {
success: true,
_triggerToolCall: { action: 'edit_slide', slide_id: payload.slideId }
};
}
// Tool action (E2B, ~30s)
case 'edit_slide': {
var comments = await context.managedDb.query(
'SELECT * FROM edits WHERE slide_id=$1 AND status=$2', [input.slide_id, 'pending']
);
var newImage = await generateImage(comments);
await context.managedDb.query(
'UPDATE slides SET image=$1, status=$2 WHERE id=$3', [newImage, 'ready', input.slide_id]
);
// Push the result to the widget
var deckData = await loadFullDeck(context, deckId);
await context.widget.update({ event: 'deck_loaded', ...deckData });
return { success: true };
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
Performance Tips
Avoid Heavy Queries in Widget Actions
Widget actions run in isolated-vm where each managedDb.query() call goes through an RPC bridge (~1-4s per query). Minimize the number of queries:
Bad — Full data reload after every small change:
case 'add_item': {
await context.managedDb.query('INSERT INTO items ...');
var allItems = await context.managedDb.query('SELECT * FROM items'); // Slow!
await context.widget.update({ event: 'items_loaded', items: allItems.rows });
return { success: true };
}2
3
4
5
6
Good — Return just the new data, update the widget optimistically:
case 'add_item': {
var res = await context.managedDb.query(
'INSERT INTO items (name) VALUES ($1) RETURNING id, name', [payload.name]
);
await context.widget.update({
event: 'item_added',
item: res.rows[0]
});
return { success: true };
}2
3
4
5
6
7
8
9
10
Your widget JS then handles item_added by pushing to its local array and re-rendering — no full reload needed.
Use _triggerToolCall for Anything Over 10 Seconds
If your widget action would take more than ~10 seconds (API calls, file processing, AI generation), split it:
- Widget action: Update status in DB + update widget UI (2-5s)
_triggerToolCall: Heavy work in E2B (up to 10 minutes)
Widget UI Catalog
For a comprehensive design system with premium components, workflow patterns, screen templates, and data contracts for building polished widget UIs, see the Widget UI Catalog.