Skip to content

PIE Agent Development - Complete Reference

This is a single-file reference for building PIE agents. Copy this entire document for use with AI coding assistants like Cursor.

Overview

PIE (Personal Intelligence Engine) is an AI assistant platform. Agents (like plugins) extend PIE with:

  • Skills - Prompt templates: always-on (every conversation) or on-demand prompt tools (AI invokes when relevant)
  • Tools - Executable code the AI can call
  • Widgets - Authenticated visual UIs inside PIE
  • Public Apps - Shareable web pages and routes served by PIE
  • Connectors - OAuth-authenticated API integrations
  • Automations - Scheduled tasks and webhooks

Agent Structure

Every agent has:

  1. Manifest (JSON) - Configuration, triggers, parameters
  2. Code (JavaScript) - Handler function that runs when invoked
  3. Optional Widget UI (===widget===) - Authenticated UI inside PIE
  4. Optional Public App Files (===public-file=== + ===public-config===) - Shareable frontend pages
js
// Basic agent code structure
async function handler(input, context) {
  // input: Parameters from AI or automation trigger
  // context: PIE APIs (fetch, secrets, oauth, ai, notify, db, managedDb, widget, publicApp, files, pdf, tasks, streamEvent, session metadata)
  
  return { result: 'data for AI' };
}

module.exports = { handler };
// OR for multiple handlers:
// module.exports = { handler, onConnect, onWebhook };

Manifest Schema

json
{
  "trigger": "always" | "auto" | "manual",
  
  "tool": {
    "name": "tool_name",
    "description": "What this tool does",
    "parameters": {
      "type": "object",
      "properties": {
        "param1": { "type": "string", "description": "..." },
        "param2": { "type": "number" }
      },
      "required": ["param1"]
    }
  },
  
  "oauth": {
    "provider": "google" | "github" | "slack" | "notion" | "custom",
    "scopes": ["scope1", "scope2"],
    "clientIdSecret": "CLIENT_ID_SECRET_NAME",
    "clientSecretSecret": "CLIENT_SECRET_SECRET_NAME"
  },
  
  "automation": {
    "triggers": [
      { "type": "cron", "default": "0 9 * * *" },
      {
        "type": "webhook",
        "eventTypeField": "_headers.x-github-event",
        "events": [
          { "name": "push", "description": "Push to a branch" },
          { "name": "pull_request", "description": "PR opened/closed/merged" }
        ]
      }
    ],
    "timeout": 60,
    "allowManualRun": true,
    "onWebhook": true
  },
  
  "developerSecrets": {
    "API_KEY": { "description": "Your API key", "required": true }
  },

  "userSecrets": {
    "USER_API_KEY": { "description": "User's own API key", "required": true }
  },

  "database": {
    "tables": {
      "notes": {
        "columns": {
          "id": "uuid primary key default gen_random_uuid()",
          "pie_user_id": "text not null default current_setting('app.user_id', true)",
          "title": "text not null",
          "content": "text",
          "created_at": "timestamptz default now()"
        },
        "rls": "pie_user_id"
      }
    }
  },

  "runtime": {
    "persistent": true,
    "timeoutMs": 300000,
    "maxSessionAge": 86400000
  },

  "userFields": {
    "topics": { "type": "tags", "label": "Topics", "default": ["tech"] },
    "maxResults": { "type": "number", "label": "Max Results", "min": 1, "max": 10, "default": 5 }
  }
}

Public App Packaging (.pie fenced sections)

Public apps are authored in extra fenced sections of the .pie file. They are not stored inside the JSON manifest itself.

text
===public-config===
entry: index.html
routes:
  - path: /
    spa: true
===public-config===

===public-file:index.html===
<!DOCTYPE html>
<html>...</html>
===public-file:index.html===

===public-file:app.js===
console.log('hello');
===public-file:app.js===
  • ===public-config=== defines the entry file and SPA fallback routes
  • ===public-file:path=== adds a static file to the public bundle
  • Public bundle limits: 50 files, 512 KB per file, 2 MB total
  • Paths must be relative, must not start with /, and must not contain ..

Trigger Types

TriggerUse Case
"always"Always-on skills - prompt active in every conversation
"auto"Tools/Connectors/On-demand skills - AI decides when to call
"manual"User must explicitly invoke

User Field Types

TypeOptions
textplaceholder
numbermin, max
boolean-
selectoptions: [{ value, label }]
tagsplaceholder

Context API

The context object provides all PIE capabilities:

context.fetch(url, options)

Make HTTP requests (logged for auditing):

js
const response = await context.fetch('https://api.example.com/data', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ key: 'value' })
});

// Response: { ok: boolean, status: number, body: string }
const data = JSON.parse(response.body);

Limits: 10 requests per execution, 4 minute (240s) timeout per request, 120s overall handler timeout, 10MB response max.

context.secrets

Access developer secrets:

js
const apiKey = context.secrets.MY_API_KEY;

context.userConfig

Access user-configured settings:

js
const { topics, maxResults } = context.userConfig;

context.user

js
const { id, displayName } = context.user;

context.oauth (Connectors only)

js
// Check connection
const connected = await context.oauth.isConnected();

// Make authenticated request (auto-refreshes tokens)
const response = await context.oauth.fetch('https://api.service.com/endpoint');

// Get connection info
const info = await context.oauth.getConnectionInfo();
// { connected, email, provider, connectedAt }

context.ai

js
// Full chat completions with tool calling and multimodal support
const result = await context.ai.chat({
  messages: [
    { role: 'system', content: 'You are a helpful assistant.' },
    { role: 'user', content: 'Summarize this page.' },
  ],
  tools: [/* OpenAI-format tool definitions */],
  model: 'openai/gpt-5.4',
  reasoningEffort: 'low',       // 'low' | 'medium' | 'high'
  temperature: 0.7,             // optional
});
// result: { content: string|null, toolCalls: [{ id, name, arguments }], usage: { ... } }

// Multimodal (images) — use content arrays
const result = await context.ai.chat({
  messages: [
    { role: 'user', content: [
      { type: 'text', text: 'What is on this page?' },
      { type: 'image_url', image_url: { url: 'data:image/png;base64,...' } },
    ]},
  ],
});

// Analyze/classify data (simpler helper)
const result = await context.ai.analyze({
  prompt: 'Classify as urgent or not. Return JSON: {"label": "urgent|not_urgent"}',
  data: { subject, body }
});

// Summarize text
const summary = await context.ai.summarize(longText);

// List available models at runtime
const models = await context.ai.listModels();
// [{ id, name, provider, description }, ...]

chat is the most powerful method — supports tool calling, multimodal inputs, and multi-turn conversations. analyze and summarize are simpler helpers. All accept an optional model parameter. Default for chat: openai/gpt-5.4. Default for analyze/summarize: google/gemini-3-flash-preview.

All AI calls are routed through OpenRouter — no separate API key needed. Billing is automatic.

Supported Models:

Model IDNameProvider
openai/gpt-5.4GPT-5.4OpenAI
google/gemini-3-flash-previewGemini 3 FlashGoogle
google/gemini-3-pro-previewGemini 3 ProGoogle
google/gemini-3.1-pro-previewGemini 3.1 ProGoogle
google/gemini-2.5-flash-liteGemini 2.5 Flash LiteGoogle
anthropic/claude-sonnet-4.6Claude Sonnet 4.6Anthropic
anthropic/claude-opus-4.6Claude Opus 4.6Anthropic
anthropic/claude-sonnet-4.5Claude Sonnet 4.5Anthropic
anthropic/claude-haiku-4.5Claude Haiku 4.5Anthropic
openai/gpt-5.2-chatGPT-5.2OpenAI
openai/gpt-5.2-proGPT-5.2 ProOpenAI
openai/gpt-5.2-codexGPT-5.2 CodexOpenAI
openai/gpt-5-miniGPT-5 MiniOpenAI
openai/gpt-5-nanoGPT-5 NanoOpenAI
openai/gpt-4oGPT-4oOpenAI
openai/gpt-4o-miniGPT-4o MiniOpenAI
moonshotai/kimi-k2.5Kimi K2.5Moonshot
x-ai/grok-4.1-fastGrok 4.1 FastxAI
deepseek/deepseek-v3.2DeepSeek V3.2DeepSeek
minimax/minimax-m2.5MiniMax M2.5MiniMax

Use context.ai.listModels() at runtime for the latest list.

context.notify() (Automations only)

js
await context.notify('Your message (supports **markdown**)', {
  title: 'Optional Title',
  urgent: true
});

context.streamEvent(type, data)

Push a real-time event to the client during tool execution. Delivered immediately — the user sees it while your handler is still running.

js
await context.streamEvent('browser_live_view', {
  url: 'https://live-view-url.example.com',
  sessionId: 'abc-123',
});

Built-in types: browser_live_view (shows live browser card), browser_needs_input (prompts user to take control).

context.tasks

Create and manage scheduled tasks (reminders, crons, heartbeats) for the user.

js
// Create a recurring task
const task = await context.tasks.create({
  name: 'Daily standup',
  taskType: 'heartbeat',
  schedule: { kind: 'cron', expr: '0 9 * * 1-5' },
  payload: { kind: 'heartbeat', message: 'Time for standup!' },
  deleteAfterRun: false,
});

// One-time reminder
await context.tasks.create({
  name: 'Call dentist',
  taskType: 'heartbeat',
  schedule: { kind: 'once', atMs: Date.now() + 86400000 },
  payload: { kind: 'heartbeat', message: 'Reminder: Call the dentist!' },
  deleteAfterRun: true,
});

// List, update, delete
const tasks = await context.tasks.list();
await context.tasks.update(taskId, { enabled: false });
await context.tasks.delete(taskId);

Schedule kinds: once (atMs), cron (expr), interval (everyMs).

context.billing.chargeUsage()

Charge the user a custom amount during execution. For variable-cost operations like phone calls, API pass-through, or data processing. Requires customUsageEnabled in plugin pricing.

js
await context.billing.chargeUsage({
  amount: 150000,                          // microdollars ($0.15)
  description: '3-min call to +1-555-1234', // shown in billing history
});
// { success: true, charged: 150000 }

Limits: min 100 microdollars, max maxCustomChargeMicrodollars (default $5.00), 10 calls per execution, description 1-200 chars.

context.db.query() (External Postgres)

Run read-only queries against an external Postgres database. Only SELECT, WITH, and EXPLAIN are allowed — mutations are blocked.

js
const result = await context.db.query({
  connection: {
    connectionString: context.secrets.POSTGRES_URL,
    ssl: 'require',
  },
  sql: 'SELECT id, email FROM users ORDER BY created_at DESC LIMIT 25',
  params: [],
  timeoutMs: 20000,
  maxRows: 1000,
});

// result: { rows, columns, rowCount, truncated, executionTimeMs, statementType }

Safety model: Read-only, single-statement only. Mutating/admin SQL is blocked. Queries run in read-only transaction mode.

context.managedDb.query() (PIE-Managed Developer DB)

Full CRUD access to your PIE-managed PostgreSQL database. No connection credentials needed — handled automatically. Every query has the end-user's ID injected as a session variable (app.user_id) for Row-Level Security.

js
// SELECT (with RLS, automatically filtered to current user)
const result = await context.managedDb.query(
  'SELECT * FROM notes ORDER BY created_at DESC'
);

// INSERT (pie_user_id auto-populated via column default)
await context.managedDb.query(
  'INSERT INTO notes (title, content) VALUES ($1, $2)',
  ['My Note', 'Note content']
);

// With options
const result = await context.managedDb.query(
  'SELECT * FROM large_table',
  [],
  { timeoutMs: 10000, maxRows: 5000 }
);

Response: { rows, columns, rowCount, truncated, executionTimeMs }

Session variables set on every query:

VariableValueAccess with
app.user_idEnd-user's PIE user IDcurrent_setting('app.user_id')
app.plugin_idYour plugin's IDcurrent_setting('app.plugin_id')

Limits: 30s statement timeout, 10,000 max rows, 5MB max response.

context.db.query()context.managedDb.query()
DatabaseExternal (you provide credentials)PIE-managed (automatic)
AccessRead-onlyFull CRUD
User contextNoneapp.user_id injected
Use caseQuerying user's own databasesStoring plugin data

context.publicApp

context.publicApp is available when your plugin uses PIE public apps. It has two different shapes depending on the runtime.

In widget runtimes (when a public app is already deployed for the current developer/user):

js
context.publicApp
// {
//   instanceId: '...',
//   instanceSlug: 'pie-forms-e8e807f3',
//   pluginSlug: 'pie-forms',
//   baseUrl: 'https://your-pie-domain.com'
// }

Use this to build share URLs:

js
const shareUrl =
  context.publicApp.baseUrl +
  '/apps/' +
  context.publicApp.pluginSlug +
  '/' +
  context.publicApp.instanceSlug;

In public action runtimes (browser calls to /api/public-actions/{instanceIdOrSlug}/{actionId}):

js
// Input
{
  _publicAction: true,
  actionId: 'load_form',
  payload: { ... },
  instanceId: '...',
  visitorIp: '...'
}

// Context
context.publicApp
// {
//   instanceId: '...',
//   visitorIp: '...',
//   respond(data) { ... }
// }

Use context.publicApp.respond(data) to set the JSON payload returned to the browser:

js
if (input._publicAction && input.actionId === 'load_form') {
  const form = await context.managedDb.query(
    'SELECT id, title FROM forms WHERE id = $1',
    [input.payload.formId]
  );
  context.publicApp.respond({ form: form.rows[0] || null });
  return { success: true };
}

Important: public actions are anonymous browser requests. In this runtime, context.user.id is the plugin owner's user ID, and context.managedDb.query() runs with the plugin owner's managed database access. Anonymous visitors are not automatically isolated by RLS, so you must validate public IDs, slugs, or tokens yourself.

context.files

Upload, list, and manage files in the user's PIE storage. Uploaded documents are automatically indexed in the user's knowledge base.

js
// Upload a file (AI auto-generates filename, tags, description)
const uploaded = await context.files.upload('report.pdf', 'application/pdf', base64Data);
// { id, filename, originalFilename, mimeType, size, tags, description, url }

// List files (optionally filter by MIME type)
const files = await context.files.list();
const images = await context.files.list('image/*');

// Get file metadata / download URL / delete
const file = await context.files.get('file-id');
const url = await context.files.getUrl('file-id');    // short-lived signed URL (~15 min)
await context.files.delete('file-id');                 // only files your agent created

Returning files inline in chat:

js
return {
  success: true,
  file: uploaded,  // PIE displays images inline in chat
  description: 'Monthly revenue chart',
};

Quota: 250 MB per agent per user.

context.pdf.extractText(url, options?)

Download a PDF from a URL and extract its text content:

js
const result = await context.pdf.extractText('https://example.com/document.pdf');
// { success, text, pages, info: { title, author, ... } }

// Limit pages
const result = await context.pdf.extractText(url, { maxPages: 50 });

Limits: 20MB max PDF size, 200 max pages, 30s download timeout.

context.getSessionMetadata() / context.updateSessionMetadata()

Read and write persistent sandbox session metadata. Only available when runtime.persistent: true.

js
// Read metadata
const meta = await context.getSessionMetadata();
// Returns {} if no metadata set

// Merge new key-value pairs (shallow merge, existing keys preserved)
await context.updateSessionMetadata({
  initialized: true,
  repoUrl: 'https://github.com/org/repo',
  totalRuns: (meta.totalRuns || 0) + 1,
});

// End the session (kills sandbox instead of pausing)
await context.updateSessionMetadata({ requestKill: true });

Metadata persists across pause/resume cycles and even after kills (for context restoration).

Sandbox Templates

If your agent needs heavy npm packages or system dependencies, create a sandbox template in the Developer Portal (Templates tab). Templates pre-install packages during a one-time build, so your agent starts instantly. Assign a template to your agent in Settings > Sandbox Template. PIE provides system templates like "Browser" (Playwright + Browserbase SDK) out of the box.


Widget API

Widgets are sandboxed iframes that give plugins a visual UI. They communicate with the host via a PIE SDK injected automatically. Widgets are defined in the ===widget=== section of a .pie file. Skills cannot have widgets.

If you need a shareable URL for anonymous visitors instead of an authenticated in-app iframe, use a public app and the public actions API.

Manifest Widget Config

json
{
  "widget": {
    "allowedScripts": [],
    "allowedStyles": [],
    "maxHeight": 600,
    "defaultHeight": 280,
    "launchable": true
  }
}
  • allowedScripts / allowedStyles: HTTPS CDN URLs to load (e.g. Chart.js, Prism.js)
  • maxHeight: max iframe height in px (100–800, default 600)
  • defaultHeight: initial height in px (default 280)
  • launchable: if true, widget appears in agent launcher (default false)

PIE SDK (available inside widget iframe)

js
PIE.onData(function(data) { /* receive data from handler */ });
PIE.sendAction('action_name', { key: 'value' });
PIE.resize(400);
PIE.onTheme(function(theme, colors) { /* 'light' or 'dark', optional colors.accent */ });
  • PIE.onData(cb) — called when handler sends data via context.widget.show(data) or context.widget.update(data). Also receives action responses with data.__actionMeta: { ok, error, result }.
  • PIE.sendAction(actionId, payload) — triggers the handler; handler receives input.action = actionId, input.payload, and input._widgetAction = true.
  • PIE.resize(height) — request iframe height change (clamped to maxHeight / 800px).
  • PIE.onTheme(cb) — receive host theme changes. theme is 'light' or 'dark'; colors may have accent.

Widget UI Design System

Use CSS custom properties for all colors, spacing, and typography so widgets respond to theme changes and match the PIE host app aesthetic.

Required design tokens (define on :root):

css
:root {
  --bg: #FFFFFF; --bg-secondary: #FAFAFA; --bg-tertiary: #F5F5F5;
  --fg: #171717; --fg-secondary: #525252; --fg-muted: #A3A3A3;
  --accent: #FF0066; --accent-hover: #FF3385;
  --accent-soft: rgba(255, 0, 102, 0.08);
  --success: #22C55E; --success-soft: rgba(34, 197, 94, 0.10);
  --warning: #F59E0B; --warning-soft: rgba(245, 158, 11, 0.10);
  --danger: #EF4444; --danger-soft: rgba(239, 68, 68, 0.08);
  --border: #E5E5E5; --border-light: rgba(0, 0, 0, 0.06);
  --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05);
  --shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -2px rgba(0, 0, 0, 0.1);
  --radius-sm: 6px; --radius: 10px; --radius-lg: 14px; --radius-full: 9999px;
  --font: 'Inter', system-ui, -apple-system, sans-serif;
  --font-mono: 'JetBrains Mono', Menlo, Monaco, monospace;
  --text-xs: 11px; --text-sm: 13px; --text-base: 14px; --text-lg: 16px;
  --space-1: 4px; --space-2: 8px; --space-3: 12px; --space-4: 16px; --space-6: 24px;
  --duration-fast: 100ms; --duration-normal: 200ms;
  --easing: ease;
}

Dark mode via PIE.onTheme (use PIE's deep dark palette):

js
PIE.onTheme(function(theme, colors) {
  var r = document.documentElement.style;
  if (theme === 'dark') {
    r.setProperty('--bg', '#0A0A0F');
    r.setProperty('--bg-secondary', '#111118');
    r.setProperty('--bg-tertiary', '#1A1A24');
    r.setProperty('--fg', '#F0F0F5');
    r.setProperty('--fg-secondary', '#8888A0');
    r.setProperty('--fg-muted', '#525260');
    r.setProperty('--accent-soft', 'rgba(255, 0, 102, 0.15)');
    r.setProperty('--border', '#262626');
    r.setProperty('--border-light', 'rgba(255, 255, 255, 0.06)');
    r.setProperty('--shadow-sm', '0 1px 2px rgba(0, 0, 0, 0.3)');
    r.setProperty('--shadow-md', '0 4px 6px rgba(0, 0, 0, 0.4)');
  } else {
    r.setProperty('--bg', '#FFFFFF');
    r.setProperty('--bg-secondary', '#FAFAFA');
    r.setProperty('--bg-tertiary', '#F5F5F5');
    r.setProperty('--fg', '#171717');
    r.setProperty('--fg-secondary', '#525252');
    r.setProperty('--fg-muted', '#A3A3A3');
    r.setProperty('--accent-soft', 'rgba(255, 0, 102, 0.08)');
    r.setProperty('--border', '#E5E5E5');
    r.setProperty('--border-light', 'rgba(0, 0, 0, 0.06)');
    r.setProperty('--shadow-sm', '0 1px 2px rgba(0, 0, 0, 0.05)');
    r.setProperty('--shadow-md', '0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -2px rgba(0, 0, 0, 0.1)');
  }
  if (colors && colors.accent) r.setProperty('--accent', colors.accent);
});

Premium rules:

  • Never show raw browser defaults. Style all selects, checkboxes, buttons.
  • Minimum 32px touch targets. Use var(--fg-secondary) for labels, var(--fg-muted) for hints.
  • Enable Inter font features: font-feature-settings: 'cv11', 'ss01'.
  • Use var(--shadow-sm) for cards at rest, var(--shadow-md) on hover.
  • Animate state changes with transition: all var(--duration-normal) var(--easing).
  • Buttons respond to click with transform: scale(0.97).
  • Use backdrop-filter: blur() for frosted overlays and sticky headers.
  • Scrollbars: slim 6px translucent thumbs, never default system scrollbars.
  • Selection color: ::selection { background: rgba(255, 0, 102, 0.2); }.
  • Limit accent color to primary actions and active states. Everything else is neutral.

Primitive components:

  • Buttons: .btn + .btn-primary (accent bg, :active { scale(0.97) }), .btn-secondary (bg + border), .btn-ghost, .btn-danger, .btn-sm, .btn-icon (:active { scale(0.92) }), .btn[disabled] with spinner
  • Inputs: .input, .select, .textarea with focus ring (accent border + accent-soft glow), .search-input, .field + .field-label
  • Tabs: .tabs container with .tab buttons and .tab.active (elevated bg + shadow)
  • Cards: .card with border + shadow-sm, hover shadow-md, .card-title, .card-meta
  • Badges: .badge (18px pill), .badge-accent, .badge-success, .badge-warning, .badge-danger
  • Lists: .list-row with hover bg + .active accent-soft, .list-row-title, .list-row-sub
  • Tables: .table with sticky header, hover rows, .table tr.selected td with accent-soft
  • Stats: .stat-card / .stat-value / .stat-label, .kv-row / .kv-label / .kv-value
  • Loading: .loading + .loading-spinner, .skeleton (shimmer)
  • Empty states: .empty-state / .empty-icon / .empty-title / .empty-desc
  • Feedback: .toast (frosted with backdrop-filter: blur(20px)), .banner, .progress-bar / .progress-fill
  • Timeline: .timeline / .timeline-item / .timeline-marker
  • Dialogs: .confirm-dialog (frosted overlay) + .confirm-box (scale-in animation)

Screen templates (pick the right one for your widget):

  1. Master-Detail — list on left, detail on right. For: CRM records, file browsers, plugin directories.
  2. Search + Filters + Results — sticky search header, scrollable results grid. For: travel search, job boards, product catalogs.
  3. KPI Dashboard — headline stats row + chart/content area. For: analytics, finance, monitoring.
  4. Table + Inspector — full-width data table with side detail panel. For: SQL results, audit logs, inventory.
  5. Document Editor — toolbar + editor + optional preview/sidebar. For: markdown editors, blog posts, slide decks.
  6. Queue / Review — item list with approve/reject actions. For: moderation, AI review, approval workflows.
  7. Background Job Monitor — progress indicator + status + result/retry. For: generation progress, exports, batch ops.
  8. Settings / Setup — vertical form sections with validation. For: OAuth connect, config, onboarding checklists.
  9. Launchable Workspace — full-panel chrome with header/body/footer. For: standalone mini-apps, task managers.
  10. Compact Inline — minimal card for chat-adjacent responses. For: quick results, confirmations, status.

Workflow patterns (event flow):

  • Search → Filter → Inspect → Act: sendAction('search') → onData results → detail → sendAction('act') → __actionMeta
  • Queue → Review → Approve/Reject → Next: onData queue → approve/reject → __actionMeta → advance
  • Generate → Preview → Accept/Regenerate: onData progress → onData result → accept or regenerate
  • Run Job → Progress → Complete/Retry: sendAction start → onData progress → onData complete/failed → retry
  • Browse → Select → Compare → Confirm: onData items → select → compare → sendAction confirm

Data contract patterns:

js
// Standard onData handler
PIE.onData(function(data) {
  if (data.__actionMeta) {
    if (data.__actionMeta.ok) showToast('success', 'Done');
    else showToast('error', data.__actionMeta.error);
    return;
  }
  switch (data.type || data.event) {
    case 'init': /* full state */ break;
    case 'progress': /* update progress bar */ break;
    case 'error': showBanner('error', data.message); break;
  }
  render();
});

// Auto-resize helper
function autoResize() {
  var h = Math.min(Math.max(document.getElementById('app').scrollHeight + 24, 200), 600);
  PIE.resize(h);
}

// Toast helper
function showToast(type, msg) {
  var el = document.createElement('div');
  el.className = 'toast toast-' + type;
  el.textContent = msg;
  document.body.appendChild(el);
  setTimeout(function() { el.remove(); }, 3000);
}

Public Apps

Public apps let a plugin ship real browser pages served by PIE at shareable URLs.

Hosted URLs

Hosted public apps use this structure:

text
/apps/{pluginSlug}/{instanceSlug}
/apps/{pluginSlug}/{instanceSlug}/{path}

Examples:

  • /apps/pie-forms/pie-forms-e8e807f3
  • /apps/pie-forms/pie-forms-e8e807f3/?f=form-id
  • /apps/pie-forms/pie-forms-e8e807f3/assets/app.js

Custom domains can also be mapped to the same bundle after verification.

Widgets vs Public Apps

  • Widget: authenticated iframe inside PIE, uses the injected PIE SDK
  • Public app: normal browser page, no PIE SDK, uses browser fetch() and public actions

Browser -> Server Flow

Call your plugin from a public page with:

js
const response = await fetch(`/api/public-actions/${encodeURIComponent(instanceId)}/load_form`, {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ formId }),
});

const result = await response.json();
// { success: true, data: ... }

On the server side, route it with input._publicAction:

js
async function handler(input, context) {
  if (input._publicAction) {
    switch (input.actionId) {
      case 'load_form': {
        const result = await context.managedDb.query(
          'SELECT id, title FROM forms WHERE id = $1',
          [input.payload.formId]
        );
        context.publicApp.respond({ form: result.rows[0] || null });
        return { success: true };
      }
    }
  }
}

Public Uploads

Anonymous visitors can upload a file to plugin-owned PIE storage through:

text
POST /api/public-actions/{instanceIdOrSlug}/upload

This route accepts multipart/form-data with one file and returns:

json
{
  "success": true,
  "data": {
    "fileId": "...",
    "filename": "resume.pdf",
    "url": "https://..."
  }
}

Current limits:

  • 10 MB max per upload
  • 1 file per request
  • allowed types include images, PDF, text, audio, video, Office docs, zip, JSON, and XML

Security Model

Public actions are anonymous. context.managedDb.query() still runs with the plugin owner's managed database access, so public handlers must validate what the visitor is allowed to read or write using your own public IDs, slugs, or tokens.

Database

See Developer Database section for context.managedDb usage.


Agent Types

1. Skill (Always-On)

Prompt template injected into every conversation. Best for personality, tone, and formatting:

Manifest:

json
{ "trigger": "always" }

Body (the prompt itself):

You are a helpful coding assistant specializing in Python.
Always suggest best practices and include error handling.
When writing code, add helpful comments.

1b. Skill (On-Demand / Prompt Tool)

Domain expertise invoked by the AI only when relevant. The prompt template is returned as the tool result — no executable code needed. Best for large knowledge bases and specialized analysis:

Manifest:

json
{
  "trigger": "auto",
  "tool": {
    "name": "analyze_nda",
    "description": "Analyze an NDA for risks, unusual clauses, and missing protections",
    "parameters": {
      "type": "object",
      "properties": {
        "document_text": {
          "type": "string",
          "description": "The full NDA text to analyze"
        }
      },
      "required": ["document_text"]
    }
  }
}

Body (expert knowledge returned when the tool is called):

You are an expert contract attorney specializing in NDAs.
Analyze the agreement for: risks, red flags, missing protections...
(Can be very large — entire knowledge bases work here)

2. Tool

Executable code the AI calls with parameters:

Manifest:

json
{
  "trigger": "auto",
  "developerSecrets": {
    "WEATHER_API_KEY": { "required": true }
  },
  "tool": {
    "name": "get_weather",
    "description": "Get current weather for a city",
    "parameters": {
      "type": "object",
      "properties": {
        "city": { "type": "string", "description": "City name" }
      },
      "required": ["city"]
    }
  }
}

Code:

js
async function handler(input, context) {
  const { city } = input;
  const apiKey = context.secrets.WEATHER_API_KEY;
  
  const response = await context.fetch(
    `https://api.weatherapi.com/v1/current.json?key=${apiKey}&q=${encodeURIComponent(city)}`
  );
  
  if (!response.ok) {
    return { error: true, message: `Weather API error: ${response.status}` };
  }
  
  const data = JSON.parse(response.body);
  return {
    city: data.location.name,
    temperature: data.current.temp_f,
    condition: data.current.condition.text
  };
}

module.exports = { handler };

3. Connector (OAuth)

API integration with user authentication:

Manifest:

json
{
  "trigger": "auto",
  "oauth": {
    "provider": "google",
    "scopes": ["https://www.googleapis.com/auth/gmail.readonly"],
    "clientIdSecret": "GOOGLE_CLIENT_ID",
    "clientSecretSecret": "GOOGLE_CLIENT_SECRET"
  },
  "developerSecrets": {
    "GOOGLE_CLIENT_ID": { "required": true },
    "GOOGLE_CLIENT_SECRET": { "required": true }
  },
  "tool": {
    "name": "search_gmail",
    "description": "Search user's Gmail",
    "parameters": {
      "type": "object",
      "properties": {
        "query": { "type": "string", "description": "Search query" }
      },
      "required": ["query"]
    }
  }
}

Code:

js
async function handler(input, context) {
  const connected = await context.oauth.isConnected();
  if (!connected) {
    return { error: true, message: 'Please connect Gmail first', requiresAuth: true };
  }
  
  const response = await context.oauth.fetch(
    `https://gmail.googleapis.com/gmail/v1/users/me/messages?q=${encodeURIComponent(input.query)}`
  );
  
  if (!response.ok) {
    return { error: true, message: `Gmail API error: ${response.status}` };
  }
  
  return JSON.parse(response.body);
}

module.exports = { handler };

4. Automation

Scheduled tasks or webhook handlers:

Manifest:

json
{
  "automation": {
    "triggers": [
      { "type": "cron", "default": "0 8 * * *", "description": "Daily at 8am" }
    ],
    "allowManualRun": true,
    "timeout": 120
  },
  "oauth": {
    "provider": "google",
    "scopes": ["https://www.googleapis.com/auth/gmail.readonly"],
    "clientIdSecret": "GOOGLE_CLIENT_ID",
    "clientSecretSecret": "GOOGLE_CLIENT_SECRET"
  },
  "developerSecrets": {
    "GOOGLE_CLIENT_ID": { "required": true },
    "GOOGLE_CLIENT_SECRET": { "required": true }
  },
  "userFields": {
    "labels": { "type": "tags", "label": "Labels to Watch", "default": ["INBOX"] }
  }
}

Code:

js
async function handler(input, context) {
  const { lastRunAt, triggeredBy } = input;
  const { labels } = context.userConfig;
  
  const since = lastRunAt ? new Date(lastRunAt).toISOString() : 'newer_than:1d';
  
  const response = await context.oauth.fetch(
    `https://gmail.googleapis.com/gmail/v1/users/me/messages?q=after:${since}`
  );
  
  const emails = JSON.parse(response.body);
  
  await context.notify(`Found ${emails.messages?.length || 0} new emails`, {
    title: 'Email Summary'
  });
  
  return { processed: emails.messages?.length || 0 };
}

module.exports = { handler };

Lifecycle Hooks

For automations/connectors, export multiple handlers:

js
// Main handler (cron, manual triggers)
async function handler(input, context) { ... }

// Called when user installs the agent
async function onInstall(input, context) {
  // Initialize user-specific resources
  return { success: true };
}

// Called when user connects OAuth
async function onConnect(input, context) {
  await context.notify('Welcome! Setting up...', { title: 'Connected' });
  return { success: true };
}

// Called for incoming webhooks
async function onWebhook(input, context) {
  const payload = input.triggerData;
  return { success: true };
}

// Called when user disconnects OAuth
async function onDisconnect(input, context) {
  return { success: true };
}

// Called when user uninstalls the agent
async function onUninstall(input, context) {
  // Clean up user-specific resources
  return { success: true };
}

module.exports = { handler, onInstall, onConnect, onWebhook, onDisconnect, onUninstall };

Error Handling

Always handle errors gracefully:

js
async function handler(input, context) {
  try {
    const response = await context.fetch('https://api.example.com/data');
    
    if (!response.ok) {
      return { error: true, message: `API error: ${response.status}` };
    }
    
    return JSON.parse(response.body);
  } catch (error) {
    return { error: true, message: error.message || 'Unknown error' };
  }
}

Return Values

Success: Return structured data for the AI:

js
return { temperature: 72, condition: 'Sunny' };

Error:

js
return { error: true, message: 'Something went wrong' };

Requires Auth (connectors):

js
return { error: true, message: 'Please connect the service', requiresAuth: true };

Cron Expressions

ExpressionMeaning
0 7 * * *Daily at 7am
*/15 * * * *Every 15 minutes
0 9 * * 1-5Weekdays at 9am
0 0 1 * *First of month

OAuth Providers

Built-in providers (only need scopes):

  • google - Gmail, Drive, Calendar
  • github - Repos, Issues, PRs
  • slack - Workspaces, Channels
  • notion - Pages, Databases

Custom provider example:

json
{
  "oauth": {
    "provider": "custom",
    "providerName": "Dropbox",
    "authorizationUrl": "https://www.dropbox.com/oauth2/authorize",
    "tokenUrl": "https://api.dropboxapi.com/oauth2/token",
    "scopes": ["files.content.read"],
    "clientIdSecret": "DROPBOX_CLIENT_ID",
    "clientSecretSecret": "DROPBOX_CLIENT_SECRET"
  }
}

Best Practices

  1. Always validate input - Check required parameters exist
  2. Handle API errors - Check response.ok before parsing
  3. Use userConfig - Let users customize behavior
  4. Provide clear descriptions - Help the AI understand when to use your tool
  5. Return structured data - Makes it easier for the AI to use results
  6. Log important events - Use context.notify() for visibility
  7. Clean up on disconnect - Remove webhooks/watches in onDisconnect

Agent Pricing

Agents can be monetized with three pricing models (set via PUT /api/plugins/:id/pricing):

Pricing Models

ModelFieldDescription
Install PriceinstallPriceCentsOne-time fee on first install. Reinstalls are free.
Monthly SubscriptionmonthlyPriceCentsRecurring 30-day charge. Auto-renewed.
Per-Usage FeeperUsageMicrodollarsCharged each execution. Supports freeUsageQuota.
Custom UsagecustomUsageEnabledLets code charge variable amounts via context.billing.chargeUsage().

Models can be combined (e.g., monthly subscription + per-usage fee + custom usage).

Setting Prices (Developer)

js
// PUT /api/plugins/:id/pricing (author only)
{
  "installPriceCents": 499,        // $4.99 one-time
  "monthlyPriceCents": 299,        // $2.99/month
  "perUsageMicrodollars": 1000,    // $0.001 per use
  "freeUsageQuota": 100,           // First 100 uses free
  "customUsageEnabled": true,      // Enable context.billing.chargeUsage()
  "maxCustomChargeMicrodollars": 5000000  // Max $5.00 per charge call
}

Revenue Share

  • Developers receive 70% of gross earnings (default 30% platform fee)
  • Payouts are monthly via Stripe Connect (minimum $1.00)
  • Earnings tracked per agent per month

User Balance

  • All agent charges deduct from the user's prepaid balance
  • Users top up via Stripe Checkout or auto-refill
  • Insufficient balance prevents installation of paid agents

Heartbeats

Heartbeats are built-in automations that send scheduled messages to the AI assistant — no agent code required.

How They Work

  • Users create heartbeats from the Heartbeats page (/heartbeats)
  • A heartbeat sends a message to the AI on a schedule (interval or cron)
  • The AI processes the message with full tool support (installed agents, file operations, etc.)
  • Results appear in the PIE Assistant session

Heartbeats vs Agent Automations

HeartbeatsAgent Automations
Code requiredNoYes (JavaScript)
Created byUsersDevelopers
Trigger typesInterval, cronCron, webhook, interval
AI accessSends message to AICalls context.ai
Infrastructureuser_tasks tableuser_tasks table

API

  • POST /api/tasks - Create heartbeat
  • GET /api/tasks - List tasks
  • PATCH /api/tasks/:id - Update
  • DELETE /api/tasks/:id - Delete
  • POST /api/tasks/:id/run - Manual trigger
  • GET /api/tasks/:id/runs - Execution history

Developer Database

PIE provides each plugin with its own managed PostgreSQL database — a private schema isolated by a dedicated role.

Two Access Paths

  1. Direct connection — Use credentials in any Postgres client (Postico, pgAdmin, psql) to design your schema
  2. Runtime via plugin code — Call context.managedDb.query(sql, params) with auto-injected user context

Provisioning

Databases are auto-provisioned when you save a plugin with a database manifest section, or when code first calls context.managedDb.query(). You can also provision manually via the Database tab in the Inspector panel.

Get credentials via the Database tab or API:

bash
curl https://your-pie-domain.com/api/plugins/YOUR_PLUGIN_ID/database/credentials \
  -H "Authorization: Bearer YOUR_ACCESS_TOKEN"
# Returns: { credentials: { host, port, database, username, password, schemaName, connectionString } }

Declarative Schema (Manifest)

Define tables in the manifest's database section. PIE auto-creates tables and adds columns on every save — additive only, never drops anything:

yaml
database:
  tables:
    notes:
      columns:
        id: "uuid primary key default gen_random_uuid()"
        pie_user_id: "text not null default current_setting('app.user_id', true)"
        title: "text not null"
        content: "text"
        created_at: "timestamptz default now()"
      rls: "pie_user_id"
ScenarioAction
Table in manifest but not in DBCREATE TABLE
Column in manifest but not on tableALTER TABLE ADD COLUMN
Table/column in DB but not in manifestIgnored (no drops)

RLS Shorthand

Setting rls: "pie_user_id" on a table automatically:

  1. Enables Row-Level Security
  2. Forces RLS for the table owner
  3. Creates a policy: USING (pie_user_id = current_setting('app.user_id', true))

Every query through context.managedDb.query() then automatically filters to the current user's data.

Auto-Populating User ID

Use a column default so inserts don't need to specify the user ID:

sql
-- In column definition:
"pie_user_id": "text not null default current_setting('app.user_id', true)"
js
// Insert without specifying pie_user_id — it's auto-populated
await context.managedDb.query(
  'INSERT INTO notes (title, content) VALUES ($1, $2)',
  ['My Note', 'Content here']
);

Limits

LimitValue
Statement timeout30 seconds
Max rows per query10,000
Max response size5 MB
Max concurrent connections (direct)10

Password Rotation

Regenerate credentials if leaked via the Database tab or API:

bash
curl -X POST https://your-pie-domain.com/api/plugins/YOUR_PLUGIN_ID/database/regenerate-password \
  -H "Authorization: Bearer YOUR_ACCESS_TOKEN"

Persistent Sandboxes

Persistent sandboxes let your agent maintain state across multiple user messages. Instead of destroying the sandbox after each invocation, it's paused and resumed on the next — preserving filesystem, packages, and processes.

Lifecycle

First message:    CREATE → EXECUTE handler → PAUSE
Second message:   RESUME → EXECUTE handler → PAUSE
End session:      RESUME → EXECUTE handler → KILL (requestKill: true)

When to Use

  • Maintain filesystem state (cloned repos, incremental changes)
  • Preserve CLI authentication (gh auth, claude auth)
  • Accumulate context across multiple interactions
  • Run long-lived background processes

Don't use for simple request/response tools — ephemeral sandboxes are faster and cheaper.

Setup

  1. Add runtime.persistent: true to manifest:
yaml
manifest:
  trigger: auto
  runtime:
    persistent: true
    timeoutMs: 300000
    maxSessionAge: 86400000
  tool:
    name: my_agent
    description: Agent with persistent state
    parameters:
      type: object
      properties:
        prompt:
          type: string
      required: [prompt]
  1. Use session metadata to track state:
js
async function handler(input, context) {
  const meta = await context.getSessionMetadata();

  if (!meta.initialized) {
    // First invocation — set up environment
    await context.updateSessionMetadata({ initialized: true, setupAt: Date.now() });
  }

  // Do work...

  // End session when user requests it
  if (input.action === 'end_session') {
    await context.updateSessionMetadata({ requestKill: true });
    return { result: 'Session ended.' };
  }

  return { result: 'Done' };
}

What's Preserved

Preserved across pause/resumeNot preserved after kill
Filesystem, packages, env varsEverything (except session metadata)
Auth credentials on disk
Session metadata (in DB)Session metadata is preserved

Marketplace Discovery

When your plugin is published (public or official visibility), its description and tags are indexed for marketplace search and tool suggestions.

To maximize discoverability:

  • description: Write a clear, specific description including key use cases. Avoid vague text like "A useful tool" — write "Create, read, and manipulate Excel spreadsheets with formulas, charts, and pivot tables."
  • tags: Add specific keywords: ["spreadsheet", "excel", "xlsx", "data-analysis"] rather than generic ones like ["tool", "office"].

The AI uses this index to recommend relevant plugins to users who don't have them installed.

Built with VitePress