Skip to content

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 PIE SDK.
  • Public apps are normal browser pages served at /apps/{pluginSlug}/{instanceSlug}/... (or a custom domain).
  • Widget actions use PIE.sendAction() and context.widget.
  • Public pages use browser fetch() to call /api/public-actions/{instanceId}/{actionId} and handle responses through context.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()
  1. Your widget JS calls PIE.sendAction('my_action', { key: 'value' })
  2. The platform runs your plugin handler in isolated-vm (fast, 30s timeout, 64MB memory)
  3. Your handler receives { _widgetAction: true, actionId: 'my_action', payload: { key: 'value' } }
  4. Your handler does lightweight work (DB queries, widget updates) and returns a result
  5. Any context.widget.update() calls are collected and sent back to the iframe as PIE.onData events

Widget HTML Setup

Your widget HTML must include the PIE SDK stub and register a data handler:

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

Handler Routing

In your plugin handler, check for input._widgetAction to route widget actions:

js
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': { /* ... */ }
  }
}

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.

json
{
  "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" }
        ]
      }
    ]
  }
}

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

js
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
    }
  };
}

What Happens

  1. Isolated-vm (fast): DB update + widget update + return trigger → response sent to user immediately
  2. E2B (background): Platform calls toolSandbox.execute(pluginCode, { action: 'generate_output', job_id: '...' }) → your handler's switch(input.action) routes to the heavy action
  3. 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:

js
{
  _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
  }
}

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

js
// 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 };
}

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:

js
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 };
}

Good — Return just the new data, update the widget optimistically:

js
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 };
}

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:

  1. Widget action: Update status in DB + update widget UI (2-5s)
  2. _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.

Built with VitePress