--- url: /docs/guides/agent-pricing.md --- # Agent Pricing PIE supports a marketplace where agent developers can monetize their agents. This guide covers how pricing works for both developers and users. ## Pricing Models Agents support three pricing models that can be combined: | Model | Field | Unit | Description | |-------|-------|------|-------------| | Install Price | `installPriceCents` | cents | One-time fee on first install. Reinstalls are free. | | Monthly Subscription | `monthlyPriceCents` | cents | Recurring 30-day charge. Auto-renewed from user balance. | | Per-Usage Fee | `perUsageMicrodollars` | microdollars | Charged each time the agent executes (tool call or automation run). | | Custom Usage | `customUsageEnabled` | boolean | Lets your code charge variable amounts via `context.billing.chargeUsage()`. | You can combine models. For example, a monthly subscription with a per-usage fee, or just a one-time install price. ### Microdollars All internal amounts use **microdollars** (1 USD = 1,000,000 microdollars). This allows sub-cent precision for per-usage fees. For install and monthly prices, you set values in **cents** (e.g., `499` = $4.99). ## Setting Prices (Developers) Use the pricing API to set prices for your agent: ``` PUT /api/plugins/:id/pricing ``` **Request body:** ```json { "installPriceCents": 499, "monthlyPriceCents": 299, "perUsageMicrodollars": 1000, "freeUsageQuota": 100 } ``` This would create an agent that costs: * **$4.99** one-time install fee * **$2.99/month** subscription * **$0.001 per use** after the first 100 free uses All fields are optional and nullable. Set a field to `null` to remove that pricing model. ### Free Usage Quota The `freeUsageQuota` field lets you offer a number of free executions before per-usage charges begin. This is useful for letting users try your agent before paying. ```json { "perUsageMicrodollars": 5000, "freeUsageQuota": 50 } ``` This charges $0.005 per use, but the first 50 uses are free. ### Custom Usage Charges For agents that incur variable costs per execution (phone calls, API pass-through, data processing), enable **custom usage charging**. This lets your code charge the user any amount during execution using `context.billing.chargeUsage()`. ```json { "customUsageEnabled": true, "maxCustomChargeMicrodollars": 10000000 } ``` This enables custom charging with a max of $10.00 per individual charge call. **How it works:** 1. Enable `customUsageEnabled` via the pricing API 2. Optionally set `maxCustomChargeMicrodollars` (defaults to $5.00 / 5,000,000 microdollars if not set) 3. In your handler, call `context.billing.chargeUsage()` with the amount and a description ```js async function handler(input, context) { const callResult = await makePhoneCall(input.phoneNumber); // Charge based on actual call duration const costMicrodollars = Math.ceil(callResult.durationMinutes * 50000); // $0.05/min await context.billing.chargeUsage({ amount: costMicrodollars, description: `${callResult.durationMinutes}-min call to ${input.phoneNumber}`, }); return { success: true, duration: callResult.durationMinutes }; } ``` **Limits:** * Minimum charge: 100 microdollars ($0.0001) * Maximum charge per call: `maxCustomChargeMicrodollars` or $5.00 default * Maximum 10 charge calls per handler execution * Description: 1-200 characters (shown to users in billing history) Custom usage charges appear as "Agent Charge" in the user's billing history with the description you provide. ### Retrieving Prices ``` GET /api/plugins/:id/pricing ``` Returns the current pricing configuration (or `null` if the agent is free). ## Revenue Share * Developers receive **70%** of gross earnings (default) * PIE retains a **30%** platform fee * The platform fee percentage is stored per developer account and may vary ### Earnings Tracking Earnings are tracked per agent per month: * **Gross earnings**: Total amount charged to users * **Platform fee**: PIE's share * **Net earnings**: Amount paid out to developer ### Payouts * Payouts are processed **monthly on the 1st** via Stripe Connect * **Minimum payout**: $1.00 * Developers must complete Stripe Connect onboarding to receive payouts * Payout status progresses: `pending` -> `paid` ## Stripe Connect Setup To receive payouts, developers create a Stripe Connect Express account: 1. Go to the Developer page in PIE 2. Click **Set Up Payouts** 3. Complete the Stripe Connect onboarding flow 4. Once verified, payouts are enabled automatically You can access your Stripe dashboard at any time to view earnings and payout history. ## How Users Are Charged ### Prepaid Balance All agent charges deduct from the user's prepaid balance. Users cannot install paid agents without sufficient balance. ### Install Price When a user installs an agent with an install price: 1. PIE checks if the user previously purchased this agent (reinstalls are free) 2. If not, PIE verifies sufficient balance 3. The install price is deducted 4. The purchase is recorded to prevent re-charging ### Monthly Subscription When a user installs an agent with a monthly subscription: 1. The first month is charged immediately 2. A subscription record is created with a 30-day period 3. A daily cron job checks for expired subscriptions and auto-renews 4. If the user's balance is insufficient at renewal, the subscription lapses ### Per-Usage Fee Each time the agent executes (tool call or automation run): 1. PIE checks if the user is within the free quota 2. If beyond the quota, the per-usage fee is deducted from balance 3. Developer earnings are recorded ### Top-Ups and Auto-Refill * Users can add funds via **Stripe Checkout** (one-time top-up) * **Auto-refill** can be enabled: when balance drops below a threshold, PIE automatically charges the user's saved payment method * Default auto-refill threshold: $5.00, default refill amount: $20.00 ## Best Practices 1. **Start free** - Consider offering a free tier or generous free quota to attract users 2. **Be transparent** - Clearly describe what your agent does before users pay 3. **Price fairly** - Per-usage fees should reflect actual API costs plus a reasonable margin 4. **Use free quota** - Let users try before they buy with `freeUsageQuota` 5. **Test pricing** - You can update pricing at any time via the API --- --- url: /docs/guides/machine-capabilities.md --- # Building Machine-Aware Plugins Machine-aware plugins can interact with the user's local computer through PIE Connect. This guide shows how to declare machine capabilities in your manifest and use the `context.machine` API. ## Declaring Machine Capabilities Add a `machineCapabilities` array to your plugin manifest: ```yaml name: my-machine-plugin displayName: My Machine Plugin description: A plugin that reads machine info and clipboard tier: tool version: 1.0.0 manifest: trigger: auto machineCapabilities: - machine.info - clipboard.read tool: name: my_machine_tool description: Reads machine info and clipboard parameters: type: object properties: action: type: string enum: [info, clipboard] required: [action] ``` When a user installs this plugin, they'll be asked to approve the requested capabilities. ## Available Capabilities | Capability | Risk | Description | Platform | |---|---|---|---| | `machine.info` | Low | Read hostname, OS, uptime, memory | All | | `clipboard.read` | Medium | Read clipboard contents | All | | `clipboard.write` | Medium | Write text to the clipboard | All | | `notifications.send` | Low | Send desktop notifications | All | | `messages.read` | High | Read iMessages | macOS | | `screenshot.capture` | High | Capture screenshots | macOS | | `desktop.control` | Critical | Control mouse and keyboard | macOS | | `shell.run` | Critical | Execute shell commands | All | | `filesystem` | High | Read, write, list, search, move, copy, delete files | All | | `apps.automate` | High | Automate macOS apps via scoped AppleScript | macOS | | `browser.data` | High | Read browser tabs, history, bookmarks | macOS | | `contacts.read` | High | Read contacts from macOS Contacts | macOS | | `calendar.read` | High | Read events from macOS Calendar | macOS | | `system.control` | Medium | Volume, dark mode, wifi, battery, processes, etc. | macOS | | `file.transfer` | High | Upload files from Mac to cloud or download files to Mac (up to 100MB) | All | ## Using the context.machine API ### Check if a machine is online ```javascript const online = await context.machine.isOnline(); if (!online) { return { error: true, message: 'No machine connected. Install PIE Connect on your Mac.' }; } ``` ### Execute a capability ```javascript // Basic machine info const info = await context.machine.execute('machine.info', {}); // Read clipboard const clip = await context.machine.execute('clipboard.read', {}); // Write to clipboard await context.machine.execute('clipboard.write', { text: 'Hello from PIE!' }); // Send notification await context.machine.execute('notifications.send', { title: 'My Plugin', message: 'Task completed!' }); // Read iMessages const messages = await context.machine.execute('messages.read', { limit: 5 }); // Execute shell command const result = await context.machine.execute('shell.run', { command: 'ls -la ~/Documents', timeout: 10000 }); // Read a file const file = await context.machine.execute('filesystem', { action: 'read', path: '~/Documents/notes.txt' }); // List directory const dir = await context.machine.execute('filesystem', { action: 'list', path: '~/Desktop', recursive: false }); // Automate an app (Spotify) const track = await context.machine.execute('apps.automate', { app: 'Spotify', script: 'get name of current track' }); // Get browser tabs const tabs = await context.machine.execute('browser.data', { action: 'tabs', browser: 'safari' }); // Read contacts const contacts = await context.machine.execute('contacts.read', { search: 'John', limit: 10, fields: ['name', 'email', 'phone'] }); // Read calendar events const events = await context.machine.execute('calendar.read', { from: '2025-03-14', to: '2025-03-21', limit: 20 }); // System info const battery = await context.machine.execute('system.control', { action: 'battery' }); const volume = await context.machine.execute('system.control', { action: 'volume' }); const apps = await context.machine.execute('system.control', { action: 'running_apps' }); // Set volume await context.machine.execute('system.control', { action: 'set_volume', level: 50 }); // Toggle dark mode await context.machine.execute('system.control', { action: 'set_dark_mode', enabled: true }); // Upload a file from Mac to cloud (convenience method) const uploaded = await context.machine.upload('~/Documents/report.pdf'); console.log(uploaded.fileId, uploaded.url); // Download a file from cloud to Mac (convenience method) await context.machine.download('https://example.com/file.pdf', '~/Downloads/file.pdf'); // Upload via execute (equivalent to context.machine.upload) const result = await context.machine.execute('file.transfer', { action: 'upload', path: '~/Desktop/screenshot.png' }); // Download via execute (equivalent to context.machine.download) await context.machine.execute('file.transfer', { action: 'download', url: 'https://example.com/data.csv', savePath: '~/Documents/data.csv' }); ``` ### List registered machines ```javascript const machines = await context.machine.list(); // Returns: [{ machineId, capabilities, connectedAt }] ``` ## Handling Offline Machines Gracefully Always check if the machine is online before executing commands. If the machine is offline, return a helpful error message so the AI can guide the user: ```javascript async function handler(input, context) { const online = await context.machine.isOnline(); if (!online) { return { error: true, message: 'Your machine is not connected. Please make sure PIE Connect is running on your Mac.', }; } // ... proceed with capability execution } ``` ## Risk Levels Capabilities are categorized by risk level, which the user sees during plugin installation: * **Low** — Read-only system info, notifications * **Medium** — Clipboard access, system settings * **High** — File access, app automation, personal data (contacts, calendar, messages, browser) * **Critical** — Shell commands, direct mouse/keyboard control ## Security Model PIE uses a three-layer permission system: 1. **Plugin manifest** — Plugins must declare every capability they use in `machineCapabilities` 2. **User approval** — Users see the requested capabilities and risk levels at install time and must approve 3. **Desktop enforcement** — The desktop app only executes capabilities from its fixed registry The `apps.automate` capability rejects scripts containing: `do shell script`, `run script`, `system attribute`, `path to startup disk`, `do script`, `POSIX file`, `call method`, `store script`, `load script`, `scripting additions`. Use `shell.run` for terminal commands and `filesystem` for file operations. Every `apps.automate` response includes a `dictionary` field containing the app's scripting dictionary (sdef), fetched in parallel and cached. This tells you exactly what commands and classes the app supports — especially useful for fixing scripts after errors. ## Error Handling Machine commands can fail for several reasons: * **MACHINE\_OFFLINE** — No machine is connected * **MACHINE\_TIMEOUT** — Machine didn't respond within 60 seconds * **Permission denied** — The capability requires additional OS permissions (e.g., Full Disk Access for messages.read) * **Capability not found** — The capability is disabled or not supported ```javascript try { const result = await context.machine.execute('messages.read', { limit: 5 }); return result; } catch (err) { return { error: true, message: err.message }; } ``` ## Security Considerations * Only request capabilities your plugin actually needs * The security scanner will flag plugins that request unnecessary machine capabilities * `shell.run` and `desktop.control` are critical-risk — users must explicitly enable them * All machine commands are logged for auditing * `apps.automate` scripts are sandboxed to `tell application` blocks --- --- url: /docs/reference/context-api.md --- # Context API The `context` object is passed to your handler function. It provides access to PIE's capabilities. ## Overview ```js async function handler(input, context) { // context.fetch() - Make HTTP requests // context.db.query() - Run read-only Postgres queries // context.secrets - Access developer secrets // context.userConfig - User-configurable settings // context.user - User information // context.oauth - OAuth operations (connectors only) // context.ai - AI capabilities (chat, analyze, summarize, listModels) // context.widget - Control plugin widget (show, update, hide) // context.publicApp - Public app metadata / public action response API // context.notify - Post notifications (automations only) // context.streamEvent() - Push real-time events to the client // context.getSessionMetadata() - Read persistent sandbox session metadata // context.updateSessionMetadata() - Write persistent sandbox session metadata // context.tasks - Create and manage scheduled tasks // context.billing - Charge users custom amounts return { /* result */ }; } ``` ## context.fetch() Make HTTP requests from your agent. All requests are logged for auditing. ### Signature ```js const response = await context.fetch(url, options); ``` ### Parameters | Parameter | Type | Description | |-----------|------|-------------| | `url` | string | The URL to request | | `options` | object | Fetch options (optional) | ### Options ```js { method: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH', headers: { 'Content-Type': 'application/json', ... }, body: 'string or stringified JSON', } ``` ### Response ```js { ok: boolean, // true if status 200-299 status: number, // HTTP status code body: string, // Response body as string } ``` ### Examples **GET request:** ```js const response = await context.fetch('https://api.example.com/data'); const data = JSON.parse(response.body); ``` **POST request with JSON:** ```js const response = await context.fetch('https://api.example.com/items', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: 'New Item' }), }); ``` **With authorization header:** ```js const response = await context.fetch('https://api.example.com/protected', { headers: { 'Authorization': `Bearer ${context.secrets.API_TOKEN}`, }, }); ``` ### Limits * Maximum 10 requests per handler execution * 4 minute timeout per HTTP request (240 seconds) * Overall handler execution timeout of 120 seconds * Response body truncated at 10MB ## context.secrets Access your agent's developer secrets. ### Usage ```js const apiKey = context.secrets.MY_API_KEY; if (!apiKey) { return { error: true, message: 'API key not configured' }; } ``` ### Notes * Secrets are defined in your manifest's `developerSecrets` * Values are set when you create/configure the agent * Secrets are encrypted at rest (AES-256-GCM) * Missing secrets return `undefined` ## context.userConfig Access user-configurable settings defined in your manifest's `userFields`. Each user can customize these values through the agent settings UI. ### Usage ```js const { topics, maxResults, frequency } = context.userConfig; if (!topics || topics.length === 0) { return { error: true, message: 'Please configure your topics in settings' }; } ``` ### Notes * Values are defined per-user via the agent settings modal * Defaults to the `default` values in your `userFields` schema if not configured * Returns an empty object `{}` if no `userFields` are defined * Type-safe: returns the correct type for each field ### Example Given this manifest: ```json { "userFields": { "topics": { "type": "tags", "default": ["tech"] }, "maxResults": { "type": "number", "default": 5 } } } ``` Access in your handler: ```js async function handler(input, context) { const { topics, maxResults } = context.userConfig; // topics: ["tech"] (or user's custom array) // maxResults: 5 (or user's custom number) for (const topic of topics) { // fetch news for each topic... } } ``` See [Manifest Schema - User-Configurable Fields](/reference/manifest#user-configurable-fields) for field type documentation. ## context.user Information about the current user. ### Properties | Property | Type | Description | |----------|------|-------------| | `id` | string | User's unique ID (UUID) | | `displayName` | string | User's display name | ### Example ```js console.log(`Running for user: ${context.user.displayName}`); ``` ## context.oauth OAuth operations for connectors. Only available when your manifest includes `oauth` configuration. ### context.oauth.isConnected() Check if the user has connected OAuth for this agent. ```js const connected = await context.oauth.isConnected(); if (!connected) { return { error: true, message: 'Please connect the service first', requiresAuth: true }; } ``` ### context.oauth.fetch() Make authenticated requests. PIE automatically: * Adds the `Authorization: Bearer {token}` header * Refreshes the token if expired * Returns the response ```js const response = await context.oauth.fetch(url, options); ``` **Parameters:** Same as `context.fetch()` **Response:** ```js { ok: boolean, status: number, body: string, headers: { [key: string]: string }, } ``` **Example:** ```js const response = await context.oauth.fetch( 'https://api.github.com/user/repos', { headers: { 'Accept': 'application/vnd.github.v3+json' }, } ); if (!response.ok) { throw new Error(`GitHub API error: ${response.status}`); } const repos = JSON.parse(response.body); ``` ### context.oauth.getConnectionInfo() Get information about the OAuth connection. ```js const info = await context.oauth.getConnectionInfo(); // { // connected: true, // email: 'user@example.com', // provider: 'google', // connectedAt: '2024-01-15T10:30:00Z' // } ``` ## context.db Run read-only queries against external Postgres databases. ### context.db.query() Execute a SQL query with strict safety guardrails. ```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, // optional maxRows: 1000, // optional maxResponseBytes: 2e6, // optional }); ``` ### Parameters | Field | Type | Description | |------|------|-------------| | `connection` | object | Connection info (`connectionString` or host/user/password/database fields) | | `sql` | string | SQL query text | | `params` | array | Positional query params (optional) | | `timeoutMs` | number | Query timeout in milliseconds (optional) | | `maxRows` | number | Max rows returned (optional) | | `maxResponseBytes` | number | Max serialized response size in bytes (optional) | ### Safety model * Only read-only SQL is allowed (`SELECT`, `WITH`, and `EXPLAIN` on read queries) * Multi-statement SQL is blocked * Mutating or admin statements are blocked (`INSERT`, `UPDATE`, `DELETE`, `ALTER`, `DROP`, etc.) * Queries run in read-only transaction mode with server-side timeout and response limits ### Response ```js { rows: [{ id: 'u1', email: 'a@example.com' }], columns: ['id', 'email'], rowCount: 1, truncated: false, executionTimeMs: 42, statementType: 'select' } ``` ## context.managedDb Full CRUD access to the PIE-managed developer database. Unlike `context.db` (which requires you to provide connection credentials to an external database), the managed database is provisioned by PIE and credentials are handled automatically. The database is **automatically provisioned** on first use — you don't need to initialize it manually. If your manifest has a `database` section, tables are also created automatically on save. Every query has the end-user's ID injected as a PostgreSQL session variable (`app.user_id`), enabling Row-Level Security policies for per-user data isolation. ::: tip Declarative Schema Define your tables in the manifest's `database.tables` section and they'll be created automatically on save. See the [Manifest Schema](/reference/manifest#database-schema) reference. ::: ::: tip See the [Developer Database guide](/guides/developer-database) for setup instructions, RLS patterns, and examples. ::: ### context.managedDb.query() Execute a SQL query against your managed developer database. ```js // With RLS-enabled tables (declared with rls: "pie_user_id" in manifest), // rows are automatically filtered to the current user const result = await context.managedDb.query( 'SELECT * FROM notes ORDER BY created_at DESC' ); // Only returns notes belonging to the current user // INSERT — pie_user_id is auto-populated via column default await context.managedDb.query( 'INSERT INTO notes (title, content) VALUES ($1, $2)', ['My Note', 'Note content'] ); // For tables without RLS, filter manually const result = await context.managedDb.query( 'SELECT * FROM shared_config WHERE key = $1', ['theme'] ); ``` ### Parameters | Field | Type | Description | |------|------|-------------| | `sql` | string | SQL query text (supports full CRUD: SELECT, INSERT, UPDATE, DELETE, and DDL) | | `params` | array | Positional query params (optional) | | `opts` | object | Options object (optional) | | `opts.timeoutMs` | number | Query timeout (default 30000, max 30000) | | `opts.maxRows` | number | Max rows returned (default 1000, max 10000) | ### Response ```js { rows: [{ id: '...', user_id: '...', title: 'My Note' }], columns: ['id', 'user_id', 'title'], rowCount: 1, truncated: false, executionTimeMs: 12 } ``` ### Session Variables These PostgreSQL session variables are set on every query: | Variable | Description | |----------|-------------| | `app.user_id` | The end-user's PIE user ID | | `app.plugin_id` | Your plugin's ID | Access them in SQL with `current_setting('app.user_id', true)`. Use them in RLS policies for automatic per-user data isolation. ### Differences from context.db.query() | | `context.db.query()` | `context.managedDb.query()` | |-|-----|------| | Database | External (you provide credentials) | PIE-managed (automatic) | | Access | Read-only | Full CRUD | | User context | None | `app.user_id` injected | | Signature | `query({ connection, sql, params })` | `query(sql, params, opts)` | ## context.ai AI capabilities for plugin code. Available in tools, connectors, and automations. ### context.ai.chat() Full multi-turn chat completion with tool calling and multimodal (image) support. This is the most powerful AI method — it gives your plugin direct access to any supported LLM with the full OpenAI Chat Completions message format, including function/tool calling and image inputs. Use this when you need: * Multi-step agent loops (the model calls tools, you execute them, and feed results back) * Multimodal inputs (screenshots, images alongside text) * Fine-grained control over system prompts, message history, and tool definitions * Specific model selection (e.g., `openai/gpt-5.4` for computer use) ```js const result = await context.ai.chat({ messages: [ { role: 'system', content: 'You are a helpful assistant.' }, { role: 'user', content: 'What is the capital of France?' }, ], }); // result: { content: 'The capital of France is Paris.', toolCalls: [], usage: { ... } } ``` **With tool calling:** ```js const result = await context.ai.chat({ messages: [ { role: 'system', content: 'You are a browser automation agent.' }, { role: 'user', content: 'Navigate to example.com and get the page title.' }, ], tools: [ { type: 'function', function: { name: 'exec_js', description: 'Execute JavaScript code in the browser', parameters: { type: 'object', properties: { code: { type: 'string', description: 'JS code to run' }, }, required: ['code'], }, }, }, ], model: 'openai/gpt-5.4', reasoningEffort: 'low', }); if (result.toolCalls.length > 0) { const call = result.toolCalls[0]; // call: { id: 'call_abc123', name: 'exec_js', arguments: '{"code":"..."}' } const args = JSON.parse(call.arguments); // Execute the tool, then send results back in a follow-up chat call } ``` **With multimodal (image) input:** ```js const result = await context.ai.chat({ messages: [ { role: 'system', content: 'Describe what you see in screenshots.' }, { role: 'user', content: [ { type: 'text', text: 'What is on this page?' }, { type: 'image_url', image_url: { url: 'data:image/png;base64,...' } }, ], }, ], model: 'openai/gpt-5.4', }); ``` **Parameters:** | Parameter | Type | Description | |-----------|------|-------------| | `messages` | array | Array of chat messages in OpenAI format (`{ role, content }` or multimodal content arrays). Required. | | `tools` | array | OpenAI-format tool definitions (`{ type: 'function', function: { name, description, parameters } }`). Optional. | | `model` | string | Model ID to use (see Supported Models). Optional — defaults to `openai/gpt-5.4`. | | `temperature` | number | Sampling temperature (0–2). Optional. | | `reasoningEffort` | string | Reasoning effort level (`'low'`, `'medium'`, `'high'`). Optional — defaults to `'low'`. | **Returns:** ```js { content: string | null, // The model's text response (null if only tool calls) toolCalls: [ // Array of tool calls the model wants to make { id: string, // Unique call ID (use this in tool result messages) name: string, // Function name arguments: string, // JSON string of arguments } ], usage: { // Token usage data promptTokens: number, completionTokens: number, totalTokens: number, cost: number, // Estimated cost in USD }, } ``` **Agent loop pattern:** The most common pattern is an agent loop where you repeatedly call `context.ai.chat()`, execute any tool calls, and feed results back: ```js let messages = [ { role: 'system', content: 'You are a helpful assistant with tools.' }, { role: 'user', content: userPrompt }, ]; for (let step = 0; step < 20; step++) { const result = await context.ai.chat({ messages, tools, model: 'openai/gpt-5.4' }); if (result.toolCalls.length === 0) { // Model is done — result.content has the final answer return { answer: result.content }; } // Append the assistant's response (with tool calls) to history messages.push({ role: 'assistant', content: result.content, tool_calls: result.toolCalls.map(tc => ({ id: tc.id, type: 'function', function: { name: tc.name, arguments: tc.arguments }, })), }); // Execute each tool call and append results for (const tc of result.toolCalls) { const args = JSON.parse(tc.arguments); const output = await executeMyTool(tc.name, args); messages.push({ role: 'tool', tool_call_id: tc.id, content: typeof output === 'string' ? output : JSON.stringify(output), }); } } ``` **Notes:** * Messages follow the [OpenAI Chat Completions format](https://platform.openai.com/docs/api-reference/chat/create). Roles: `system`, `user`, `assistant`, `tool`. * Tool result messages must have `role: 'tool'` and include the `tool_call_id` from the corresponding tool call. * For multimodal messages, use content arrays with `{ type: 'text', text: '...' }` and `{ type: 'image_url', image_url: { url: '...' } }` objects. Image URLs can be `data:image/png;base64,...` or HTTPS URLs. * All calls are routed through OpenRouter — no separate API key needed. Billing is handled automatically. * The 4-minute timeout applies per call (same as `context.fetch`). ### context.ai.analyze() Use AI to analyze and classify data: ```js const result = await context.ai.analyze({ prompt: 'Classify this email as urgent or not urgent. Return JSON: {"label": "urgent|not_urgent"}', data: { subject, from, snippet } }); // result: { label: 'urgent', confidence: 0.95 } ``` **With a specific model:** ```js const result = await context.ai.analyze({ prompt: 'Generate a detailed product description', data: { name, features, audience }, model: 'google/gemini-3.1-pro-preview' }); ``` **Parameters:** | Parameter | Type | Description | |-----------|------|-------------| | `prompt` | string | Instructions for the AI | | `data` | object | Data to analyze | | `model` | string | Optional. Model ID to use (see Supported Models below). Defaults to `google/gemini-3-flash-preview`. | **Returns:** Parsed JSON from the AI response, or text if not JSON. ### context.ai.summarize() Get a text summary of content: ```js const summary = await context.ai.summarize(longArticleText); // "Brief summary of the article highlighting key points..." ``` **With a specific model:** ```js const summary = await context.ai.summarize(longArticleText, { model: 'anthropic/claude-sonnet-4.6' }); ``` **Parameters:** | Parameter | Type | Description | |-----------|------|-------------| | `content` | string | Text to summarize | | `options` | object | Optional second argument | | `options.model` | string | Optional. Model ID to use (see Supported Models below). Defaults to `google/gemini-3-flash-preview`. | **Returns:** String summary ### context.ai.listModels() Get the list of available models at runtime: ```js const models = await context.ai.listModels(); // [ // { id: 'google/gemini-3-flash-preview', name: 'Gemini 3 Flash', provider: 'Google', description: '...' }, // { id: 'anthropic/claude-sonnet-4.6', name: 'Claude Sonnet 4.6', provider: 'Anthropic', description: '...' }, // ... // ] ``` **Returns:** Array of model objects with `id`, `name`, `provider`, and `description`. ### Supported Models These are the currently available models you can pass to `context.ai.chat()`, `context.ai.analyze()`, and `context.ai.summarize()`: | Model ID | Name | Provider | |----------|------|----------| | `openai/gpt-5.4` | GPT-5.4 | OpenAI | | `google/gemini-3-flash-preview` | Gemini 3 Flash | Google | | `google/gemini-3-pro-preview` | Gemini 3 Pro | Google | | `google/gemini-3.1-pro-preview` | Gemini 3.1 Pro | Google | | `google/gemini-2.5-flash-lite` | Gemini 2.5 Flash Lite | Google | | `anthropic/claude-sonnet-4.6` | Claude Sonnet 4.6 | Anthropic | | `anthropic/claude-opus-4.6` | Claude Opus 4.6 | Anthropic | | `anthropic/claude-sonnet-4.5` | Claude Sonnet 4.5 | Anthropic | | `anthropic/claude-haiku-4.5` | Claude Haiku 4.5 | Anthropic | | `openai/gpt-5.2-chat` | GPT-5.2 | OpenAI | | `openai/gpt-5.2-pro` | GPT-5.2 Pro | OpenAI | | `openai/gpt-5.2-codex` | GPT-5.2 Codex | OpenAI | | `openai/gpt-5-mini` | GPT-5 Mini | OpenAI | | `openai/gpt-5-nano` | GPT-5 Nano | OpenAI | | `openai/gpt-4o` | GPT-4o | OpenAI | | `openai/gpt-4o-mini` | GPT-4o Mini | OpenAI | | `moonshotai/kimi-k2.5` | Kimi K2.5 | Moonshot | | `x-ai/grok-4.1-fast` | Grok 4.1 Fast | xAI | | `deepseek/deepseek-v3.2` | DeepSeek V3.2 | DeepSeek | | `minimax/minimax-m2.5` | MiniMax M2.5 | MiniMax | | `openai/gpt-oss-120b` | GPT OSS 120B | Ultra Fast | | `openai/gpt-oss-safeguard-20b:nitro` | GPT OSS Safeguard 20B | Ultra Fast | Use `context.ai.listModels()` to get the most up-to-date list at runtime, as new models may be added. ### Billing AI calls are billed based on the actual token usage of the model you select. Different models have different per-token costs — more capable models (e.g., Claude Opus 4.6, GPT-5.2 Pro) cost more per token than lightweight models (e.g., GPT-5 Nano, Gemini 2.5 Flash Lite). The cost is determined by the upstream provider (via OpenRouter) and your subscription plan's margin multiplier is applied on top. **Note:** AI calls count toward your token usage and balance. ## context.widget Control your plugin's widget iframe. Available in **widget action handlers** (isolated-vm) and **tool action handlers** (E2B). Widgets are sandboxed iframes that communicate with your plugin handler via `PIE.sendAction()` in the iframe and `context.widget` on the server side. ### context.widget.show(data) Open the widget and send initial data. If the widget is already open, updates its data. ```js await context.widget.show({ event: 'dashboard', items: [{ id: 1, name: 'Item 1' }], }); ``` ### context.widget.update(data) Push new data to an already-open widget. The widget receives this via `PIE.onData(callback)`. ```js await context.widget.update({ event: 'progress', completed: 3, total: 10, }); ``` ### context.widget.hide() Close the widget. ```js await context.widget.hide(); ``` ### `_triggerToolCall` — Background E2B dispatch from widget actions Widget actions run in isolated-vm (30s timeout, lightweight). When a widget action needs to kick off heavy work (image generation, long API calls, data processing), it can return a `_triggerToolCall` object. The platform will then dispatch a **background E2B execution** of the same plugin with the trigger object as the input — no LLM in the loop. ```js // Inside your widget action handler: case 'start_processing': { await context.managedDb.query('UPDATE jobs SET status=$1 WHERE id=$2', ['processing', payload.jobId]); await context.widget.update({ event: 'processing_started', flash: 'Processing...' }); return { success: true, _triggerToolCall: { action: 'run_heavy_job', job_id: payload.jobId } }; } ``` The `_triggerToolCall` object is passed directly as the `input` to your plugin's `handler()` function in E2B. Your handler's `switch(input.action)` routing handles it like any other tool action. **How it works:** 1. User clicks a button in the widget → `PIE.sendAction('start_processing', { jobId })` 2. Widget action runs in isolated-vm — does lightweight DB updates, sends widget update, returns `_triggerToolCall` 3. Response sent to client immediately (user sees feedback) 4. Platform dispatches `toolSandbox.execute()` in background → E2B runs the heavy action 5. E2B handler calls `context.widget.update()` when done → widget updates via SSE **Requirements:** * `_triggerToolCall` must be an object with an `action` string property * The action value should match a case in your handler's `switch(input.action)` routing * Additional parameters are passed through as-is (e.g., `slide_id`, `job_id`) See the [Widget Actions guide](/guides/widget-actions) for full examples and best practices. ## context.publicApp `context.publicApp` is available when your plugin uses PIE public apps. It has two modes depending on the runtime. ### Mode 1: Widget Runtimes When the current plugin already has a published public app instance for the current developer/user, PIE injects deployment metadata into widget actions: ```js context.publicApp // { // instanceId: '...', // instanceSlug: 'pie-forms-e8e807f3', // pluginSlug: 'pie-forms', // baseUrl: 'https://your-pie-domain.com' // } ``` Use this to build share links from widget actions: ```js const shareUrl = context.publicApp.baseUrl + '/apps/' + context.publicApp.pluginSlug + '/' + context.publicApp.instanceSlug; ``` If the plugin has no deployed public app yet, `context.publicApp` is `null` in these runtimes. ### Mode 2: Public Action Runtime When the browser calls: ```text POST /api/public-actions/{instanceIdOrSlug}/{actionId} ``` your handler receives: ```js { _publicAction: true, actionId: 'load_form', payload: { ... }, instanceId: '...', visitorIp: '...' } ``` In that runtime, `context.publicApp` looks like this: ```js context.publicApp // { // instanceId: '...', // visitorIp: '...', // respond(data) { ... } // } ``` #### `context.publicApp.respond(data)` Use `respond()` to set the JSON payload returned to the browser: ```js async function handler(input, context) { if (input._publicAction && input.actionId === '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 }; } } ``` If you do not call `respond()`, PIE returns your handler result as `data`. ### Public Action Data Model Public actions are anonymous browser requests: * `context.user.id` is the plugin owner's user ID in a public action runtime * `context.managedDb.query()` runs against the plugin owner's managed database access * Anonymous visitors are not automatically isolated by RLS That means your public action handler must validate what a visitor can access using your own public IDs, slugs, tokens, or row filters. See the [Public Apps and Routes guide](/guides/public-apps) for `.pie` packaging, hosted URL structure, SPA routing, uploads, and custom domains. ## context.files Upload, list, and manage files in the user's PIE storage. Files are scoped to your agent by default -- other agents cannot see them unless the user explicitly grants access. Uploaded documents (PDF, text, markdown, etc.) are automatically indexed in the user's knowledge base, making them searchable by the AI in chat. ### context.files.upload(filename, mimeType, base64Data) Upload a file. PIE automatically generates a descriptive filename, tags, and description using AI. ```js const result = await context.files.upload( 'report.pdf', 'application/pdf', base64EncodedData ); // result: { // id: 'abc-123', // filename: 'quarterly-sales-report-q1-2026.pdf', // AI-generated // originalFilename: 'report.pdf', // mimeType: 'application/pdf', // size: 125000, // tags: ['report', 'sales', 'quarterly'], // description: 'Q1 2026 quarterly sales report with revenue breakdown', // url: 'https://...' // short-lived signed URL // } ``` **Parameters:** | Parameter | Type | Description | |-----------|------|-------------| | `filename` | string | Original filename (with extension) | | `mimeType` | string | MIME type (e.g., `image/png`, `application/pdf`) | | `base64Data` | string | File content as base64-encoded string | **Returns:** Object with `id`, `filename`, `mimeType`, `size`, `tags`, `description`, and a short-lived `url`. **Quota:** 250 MB per agent per user. An error is thrown if the quota is exceeded. ### context.files.list(mimeTypeFilter?) List files your agent has access to (own files + files granted by the user). ```js // All files const files = await context.files.list(); // Only images const images = await context.files.list('image/*'); ``` **Parameters:** | Parameter | Type | Description | |-----------|------|-------------| | `mimeTypeFilter` | string | Optional. Filter by MIME type prefix (e.g., `image/*`) | **Returns:** Array of file objects with `id`, `filename`, `mimeType`, `size`, `tags`, `description`, `createdAt`. ### context.files.get(fileId) Get metadata for a specific file. ```js const file = await context.files.get('abc-123'); ``` ### context.files.getUrl(fileId) Get a short-lived download/preview URL (valid for ~15 minutes). ```js const url = await context.files.getUrl('abc-123'); ``` ### context.files.delete(fileId) Delete a file your agent created. ```js const success = await context.files.delete('abc-123'); ``` **Note:** You can only delete files your agent created. The user can delete any file from the Files page. ### Returning files in tool results To display a file inline in the chat (especially images), return a `file` object in your handler result: ```js async function handler(input, context) { // ... generate or fetch a file ... const uploaded = await context.files.upload( 'chart.png', 'image/png', base64ImageData ); return { success: true, file: uploaded, // PIE will display this inline in chat description: 'Monthly revenue chart', }; } ``` PIE detects the `file` property and renders images inline in the chat message. ### File Search integration When you upload a document type (PDF, text, markdown, JSON, etc.), PIE automatically indexes it in the user's knowledge base. This means: * The AI can find and cite the document when answering questions * The user can ask "What does my report say about..." and get answers from agent-uploaded files * No extra code needed -- indexing happens automatically on upload ## context.pdf Extract text content from PDF files. The PDF is downloaded from a URL and parsed server-side, so your plugin gets clean, readable text — no binary data to deal with. ### context.pdf.extractText(url, options?) Downloads a PDF from the given URL and returns the extracted text content. ```js const result = await context.pdf.extractText('https://example.com/document.pdf'); console.log(result.text); // Full text content of the PDF console.log(result.pages); // Number of pages console.log(result.info); // PDF metadata (title, author, etc.) ``` **Parameters:** | Parameter | Type | Description | |-----------|------|-------------| | `url` | string | URL to download the PDF from (must be publicly accessible or a signed/temporary URL) | | `options.maxPages` | number | Maximum number of pages to extract (default: all pages, max: 200) | **Returns:** | Field | Type | Description | |-------|------|-------------| | `success` | boolean | Whether extraction succeeded | | `text` | string | The extracted text content | | `pages` | number | Total number of pages in the PDF | | `info` | object? | PDF metadata: `title`, `author`, `subject`, `creator`, `creationDate`, `modDate` | **Example: Dropbox PDF reading** ```js // Get a temporary download link from Dropbox const linkData = await context.oauth.fetch( 'https://api.dropboxapi.com/2/files/get_temporary_link', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ path: '/Documents/report.pdf' }) } ); const link = JSON.parse(linkData.body).link; // Extract text from the PDF const pdf = await context.pdf.extractText(link); return { success: true, content: pdf.text, pages: pdf.pages }; ``` **Limits:** * Maximum PDF size: 20 MB * Maximum pages: 200 (use `options.maxPages` to limit) * Download timeout: 30 seconds ## context.notify() (Automations Only) Post messages to the user's PIE Assistant session: ```js await context.notify('Your notification message', { title: 'Optional Title', urgent: true, // Highlights the notification }); ``` **Parameters:** | Parameter | Type | Description | |-----------|------|-------------| | `message` | string | The notification message (supports markdown) | | `options.title` | string | Optional title/heading | | `options.urgent` | boolean | If true, notification is highlighted | **Example with formatting:** ```js await context.notify( `## Daily Report\n\n` + `- **Emails**: ${emailCount} new\n` + `- **Tasks**: ${taskCount} due today\n\n` + `---\n` + `*Generated at ${new Date().toLocaleString()}*`, { title: 'Morning Summary' } ); ``` ## context.streamEvent() Push a real-time event to the client during tool execution. Events are delivered immediately through the chat stream — the user sees them while your handler is still running. This is useful for long-running tools that need to provide live feedback or hand off interactive URLs. ### Signature ```js await context.streamEvent(type, data); ``` ### Parameters | Parameter | Type | Description | |-----------|------|-------------| | `type` | string | Event type identifier (e.g., `'browser_live_view'`) | | `data` | object | Arbitrary data payload delivered to the client (optional, defaults to `{}`) | ### Returns `true` on success. ### Built-in event types PIE's client recognizes these event types out of the box: | Event type | Data fields | Behavior | |------------|-------------|----------| | `browser_live_view` | `url` (string), `sessionId` (string, optional) | Shows a live browser view card in chat with "Watch Live" link and optional iframe preview | | `browser_needs_input` | `url` (string), `message` (string, optional) | Promotes the live view card to an alert state prompting the user to take control | You can also emit custom event types — they'll be delivered to the client as generic stream events. ### Example: Browser live view ```js async function handler({ task }, context) { // Create a remote browser session const session = await createBrowserSession(); const liveViewUrl = await getLiveViewUrl(session.id); // Push the live view URL to the user immediately await context.streamEvent('browser_live_view', { url: liveViewUrl, sessionId: session.id, }); // Run the browsing task (this takes 30–120 seconds) const result = await runBrowserAgent(session.id, task); return { success: true, summary: result.summary }; } ``` The user sees the "Watch Live" card within seconds, then the agent's final summary once the task completes. ### Example: Requesting user input ```js // If the agent gets stuck on a login page: await context.streamEvent('browser_needs_input', { url: liveViewUrl, message: 'Please log in with your credentials', }); // Wait for the user to act, then retry await waitForPageChange(session.id); ``` ### Limits * Maximum 20 stream events per handler execution * `type` must be 1–100 characters * `data` is serialized as JSON — keep payloads small ## context.getSessionMetadata() Retrieve the current persistent sandbox session's metadata. Only available for plugins with `runtime.persistent: true`. ### Signature ```js const metadata = await context.getSessionMetadata(); ``` ### Returns An object (`Record`) containing whatever metadata the plugin has previously stored. Returns `{}` if no metadata has been set. ### Example ```js const meta = await context.getSessionMetadata(); if (meta.initialized) { console.log(`Resuming session for repo: ${meta.repoUrl}`); } ``` ### Notes * Metadata persists across sandbox pause/resume cycles. * Metadata is stored per user per plugin — each user has their own session state. * Returns `{}` for non-persistent plugins (no error thrown). ## context.updateSessionMetadata() Merge new key-value pairs into the persistent sandbox session's metadata. Only available for plugins with `runtime.persistent: true`. ### Signature ```js await context.updateSessionMetadata(metadata); ``` ### Parameters | Parameter | Type | Description | |-----------|------|-------------| | `metadata` | object | Key-value pairs to merge into the existing metadata. Existing keys not present in the update are preserved. | ### Returns `true` on success. ### Example ```js await context.updateSessionMetadata({ initialized: true, repoUrl: 'https://github.com/org/repo', totalRuns: (meta.totalRuns || 0) + 1, }); ``` ### Special metadata keys | Key | Behavior | |-----|----------| | `requestKill` | If set to `true`, the platform will **kill** (not pause) the sandbox after the current execution completes. Use this when the user wants to end their session. | ### Example: Ending a session ```js if (action === 'end_session') { await context.updateSessionMetadata({ requestKill: true }); return { result: 'Session ended.' }; } ``` ### Notes * Updates are merged (shallow), not replaced. To remove a key, set it to `null`. * Keep metadata small — it's stored as JSONB in the database. * Metadata survives session kills for context restoration purposes. ## context.tasks Create, list, update, and delete scheduled tasks for the user. Use this to build reminders, recurring heartbeats, cron jobs, and other scheduled actions. ### context.tasks.create(options) Create a new scheduled task. ```js const task = await context.tasks.create({ name: 'Daily standup reminder', taskType: 'heartbeat', schedule: { kind: 'cron', expr: '0 9 * * 1-5' }, payload: { kind: 'heartbeat', message: 'Time for your daily standup!' }, delivery: { target: 'pie_assistant' }, deleteAfterRun: false, }); ``` **Parameters:** | Field | Type | Description | |-------|------|-------------| | `name` | string | Human-readable task name | | `taskType` | string | `'heartbeat'`, `'cron'`, or `'webhook'` | | `schedule` | object | Schedule config (see below) | | `payload` | object | What to do when the task runs | | `delivery` | object | Where to deliver (`{ target: 'pie_assistant' }`, `'email'`, or `'telegram'`) | | `description` | string | Optional description | | `deleteAfterRun` | boolean | If true, task is deleted after first run (for one-time reminders) | | `activeHours` | object | Optional `{ start: 'HH:MM', end: 'HH:MM' }` window | | `enabled` | boolean | Whether the task is active (default: true) | **Schedule types:** | Kind | Fields | Example | |------|--------|---------| | `once` | `atMs` (unix ms) | `{ kind: 'once', atMs: 1735689600000 }` | | `cron` | `expr` (cron expression) | `{ kind: 'cron', expr: '0 9 * * *' }` | | `interval` | `everyMs` (milliseconds) | `{ kind: 'interval', everyMs: 3600000 }` | **Returns:** The created task object with `id`, `name`, `schedule`, `nextRunAt`, etc. ### context.tasks.list() List all of the user's scheduled tasks. ```js const tasks = await context.tasks.list(); for (const task of tasks) { console.log(`${task.name} - next run: ${task.nextRunAt}`); } ``` **Returns:** Array of task objects. ### context.tasks.update(taskId, updates) Update an existing task. ```js const updated = await context.tasks.update('task-uuid', { schedule: { kind: 'cron', expr: '0 10 * * 1-5' }, enabled: true, }); ``` **Parameters:** | Field | Type | Description | |-------|------|-------------| | `taskId` | string | The task ID to update | | `updates` | object | Fields to update (same fields as `create`, all optional) | **Returns:** The updated task object. ### context.tasks.delete(taskId) Delete a scheduled task. ```js const success = await context.tasks.delete('task-uuid'); ``` **Returns:** `true` on success. ### Example: One-time reminder ```js const tomorrow9am = new Date(); tomorrow9am.setDate(tomorrow9am.getDate() + 1); tomorrow9am.setHours(9, 0, 0, 0); await context.tasks.create({ name: 'Call dentist', taskType: 'heartbeat', schedule: { kind: 'once', atMs: tomorrow9am.getTime() }, payload: { kind: 'heartbeat', message: 'Reminder: Call the dentist!' }, deleteAfterRun: true, }); ``` ## context.input (Automations Only) Automation-specific input data. In your handler function, `input` contains: ### Properties | Property | Type | Description | |----------|------|-------------| | `lastRunAt` | number | null | Timestamp (ms) of last successful run | | `triggeredBy` | string | `'cron'`, `'webhook'`, or `'manual'` | | `triggerData` | any | Webhook payload or lifecycle event data. For webhook triggers, includes `_headers` with selected HTTP headers. | ### Example ```js async function handler(input, context) { const { lastRunAt, triggeredBy, triggerData } = input; // Process only data since last run const sinceTime = lastRunAt || (Date.now() - 24 * 60 * 60 * 1000); if (triggeredBy === 'webhook') { // Handle webhook payload const { eventType, data } = triggerData; } // ... rest of automation logic } ``` ## Lifecycle Hooks Agents can export additional handlers for lifecycle events: ### `onConnect(input, context)` Called immediately after a user connects OAuth. Use this to: * Set up external subscriptions (webhooks, watches) * Send a welcome notification * Fetch initial data ```js async function onConnect(input, context) { // input.triggerData.event === 'onConnect' await context.notify('Welcome! Setting up your account...', { title: 'Connected' }); // Set up Gmail push notifications await context.oauth.fetch('https://gmail.googleapis.com/gmail/v1/users/me/watch', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ topicName: context.secrets.PUBSUB_TOPIC, labelIds: ['INBOX'], }), }); return { success: true }; } module.exports = { handler, onConnect }; ``` ### `onWebhook(input, context)` Called when an external service POSTs to your agent's webhook URL (`/api/webhooks/plugin/{pluginId}`). The `input.triggerData` object contains the webhook request body, plus a `_headers` object with selected HTTP headers from the request. This is useful for services like GitHub that send the event type in a header: ```js async function onWebhook(input, context) { const payload = input.triggerData; // Access HTTP headers injected by the webhook route const githubEvent = payload._headers?.['x-github-event']; // 'push', 'pull_request', 'issues', etc. // The rest of the payload is the request body const action = payload.action; // 'opened', 'closed', etc. // Decode base64 Pub/Sub message (Gmail, etc.) if (payload.message?.data) { const decoded = JSON.parse(atob(payload.message.data)); // Process decoded notification } return { success: true, processed: 5 }; } module.exports = { handler, onWebhook }; ``` **Available headers in `_headers`:** | Header | Description | |--------|-------------| | `x-github-event` | GitHub webhook event type | | `x-hub-signature-256` | GitHub HMAC signature | | `content-type` | Request content type | ::: tip Event-Triggered Heartbeats If your agent declares webhook events in its manifest (via `eventTypeField` and `events[]`), users can create heartbeats that trigger automatically when specific events arrive. The webhook payload is injected into the AI prompt as context. See [Declaring Webhook Events](/reference/manifest#declaring-webhook-events) for details. ::: ### `onDisconnect(input, context)` (Optional) Called when a user disconnects OAuth. Use this to clean up external subscriptions. ```js async function onDisconnect(input, context) { // Clean up external resources await context.oauth.fetch('https://api.example.com/unsubscribe', { method: 'DELETE' }); return { success: true }; } module.exports = { handler, onConnect, onDisconnect }; ``` ### `onInstall(input, context)` (Optional) Called when a user installs the agent. Use this to perform initial setup, provision resources, or send a welcome message. ```js async function onInstall(input, context) { // input.triggerData.event === 'onInstall' await context.notify('Thanks for installing! Let\'s get you set up.', { title: 'Welcome' }); return { success: true }; } module.exports = { handler, onInstall }; ``` ### `onUninstall(input, context)` (Optional) Called when a user uninstalls the agent. Use this to clean up any resources or external registrations. ```js async function onUninstall(input, context) { // input.triggerData.event === 'onUninstall' // Remove external webhook registrations, clean up resources, etc. await context.fetch('https://api.example.com/hooks/remove', { method: 'DELETE', headers: { 'Authorization': `Bearer ${context.secrets.API_TOKEN}` }, }); return { success: true }; } module.exports = { handler, onUninstall }; ``` ### Handler Priority When an agent is invoked, PIE checks handlers in this order: 1. `onInstall` - if `triggerData.event === 'onInstall'` 2. `onUninstall` - if `triggerData.event === 'onUninstall'` 3. `onConnect` - if `triggerData.event === 'onConnect'` 4. `onDisconnect` - if `triggerData.event === 'onDisconnect'` 5. `onWebhook` - if `triggeredBy === 'webhook'` 6. `handler` - for cron/manual triggers If the specific handler doesn't exist, PIE falls back to `handler`. ## 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 Your handler should return: **Success:** ```js return { // Structured data for the AI to use temperature: 72, condition: 'Sunny', }; ``` **Error:** ```js return { error: true, message: 'Something went wrong', }; ``` **Requires Authentication (connectors):** ```js return { error: true, message: 'Please connect the service', requiresAuth: true, }; ``` ## Sandbox Templates By default, your agent code runs in a lightweight Node.js sandbox with no extra packages. If your agent needs heavy dependencies (Playwright, Puppeteer, native binaries, ML libraries, etc.), you can create a **sandbox template** that pre-installs everything during a one-time build step. ### When you need a template * Your agent imports npm packages that aren't available in the default sandbox (e.g., `playwright`, `@browserbasehq/sdk`, `sharp`) * You need system-level packages installed via `apt-get` (e.g., `ffmpeg`, `chromium`) * You want faster cold starts by avoiding runtime installs ### Creating a template 1. Go to the **Developer Portal** and open the **Templates** tab in the right panel 2. Enter a name, display name, and a **setup script** (bash) 3. Click **Create & Build Template** The setup script runs once during the template build (not on every execution). Example: ```bash npm install playwright @browserbasehq/sdk apt-get update && apt-get install -y ffmpeg ``` PIE builds the template in the background (1-3 minutes). You'll see the status update to "Ready" when it's done. If the build fails, you'll see the error and build logs. ### Assigning a template to your agent 1. Select your agent in the Developer Portal 2. In the **Settings** tab, find the **Sandbox Template** dropdown 3. Pick your template (or a PIE-provided system template like "Browser") ### System templates PIE provides pre-built templates for common use cases: | Template | Includes | |----------|----------| | Browser (Playwright + Browserbase) | `playwright`, `@browserbasehq/sdk` | | Claude Code Agent Environment | `@anthropic-ai/claude-code`, `gh` CLI, `git`, `curl`, `jq` | System templates are available to all developers and cannot be modified. ### Notes * Templates are reusable — one template can be assigned to multiple agents * Template builds are cached — rebuilds only run when you change the setup script * The setup script runs as root in a Debian-based container * Keep setup scripts minimal for faster builds *** ## context.billing The `context.billing` API lets your agent charge users custom amounts during execution. This is for variable-cost operations like phone calls, API pass-through charges, or data processing where the cost isn't known until execution time. **Prerequisite:** You must enable `customUsageEnabled` in your plugin's pricing configuration via `PUT /api/plugins/:id/pricing`. ### context.billing.chargeUsage({ amount, description }) Charge the user a custom amount. The charge is deducted from the user's prepaid balance immediately. ```js const result = await context.billing.chargeUsage({ amount: 150000, // microdollars (required) description: '3-min call to +1-555-1234', // human-readable (required) }); // result: { success: true, charged: 150000 } ``` **Parameters:** | Parameter | Type | Description | |-----------|------|-------------| | `amount` | number | Amount in microdollars (1,000,000 = $1.00). Minimum 100. Must be an integer. | | `description` | string | Human-readable description shown in the user's billing history. 1-200 characters. | **Returns:** ```js { success: true, charged: 150000, // amount actually charged (microdollars) } ``` **Errors:** The call throws an error if: * Custom usage charging is not enabled for the plugin (403) * Amount is below the minimum (100 microdollars) * Amount exceeds `maxCustomChargeMicrodollars` (or the $5.00 default cap) * More than 10 charge calls in a single handler execution **Example: Metered phone call** ```js async function handler(input, context) { const call = await startPhoneCall(input.number); const result = await waitForCallEnd(call.id); const costPerMinute = 50000; // $0.05/min const cost = Math.ceil(result.durationMinutes * costPerMinute); await context.billing.chargeUsage({ amount: cost, description: `${result.durationMinutes}-min call to ${input.number}`, }); return { transcript: result.transcript, duration: result.durationMinutes }; } ``` **Example: API cost pass-through** ```js async function handler(input, context) { const response = await callExternalApi(input.query); if (response.cost > 0) { const costMicrodollars = Math.round(response.cost * 1_000_000); await context.billing.chargeUsage({ amount: costMicrodollars, description: `API query: ${input.query.substring(0, 50)}`, }); } return response.data; } ``` ### Limits * **Minimum charge:** 100 microdollars ($0.0001) * **Maximum charge per call:** Configurable via `maxCustomChargeMicrodollars` in pricing (default: 5,000,000 / $5.00) * **Maximum calls per execution:** 10 `chargeUsage` calls per handler invocation * **Description length:** 1-200 characters ### Revenue Share Custom usage charges follow the same revenue share as other pricing models (70% developer / 30% platform by default). Earnings appear in your developer dashboard alongside per-usage and subscription revenue. ### Configuring Custom Usage Enable and configure via the pricing API: ``` PUT /api/plugins/:id/pricing ``` ```json { "customUsageEnabled": true, "maxCustomChargeMicrodollars": 10000000 } ``` | Field | Type | Description | |-------|------|-------------| | `customUsageEnabled` | boolean | Set to `true` to allow `context.billing.chargeUsage()` calls | | `maxCustomChargeMicrodollars` | number | null | Max microdollars per charge call. `null` uses server default ($5.00). | You can also enable this from the Pricing section of the Developer Portal. *** ## context.machine The `context.machine` API allows plugins to interact with the user's connected Mac through PIE Connect. Only available if the plugin declares `machineCapabilities` in its manifest. ### context.machine.isOnline() Check if the user has an online machine. ```javascript const online = await context.machine.isOnline(); ``` ### context.machine.list() List all connected machines. ```javascript const machines = await context.machine.list(); // [{ machineId, capabilities, connectedAt }] ``` ### context.machine.execute(capability, params) Execute a command on the user's machine. ```javascript const info = await context.machine.execute('machine.info', {}); const clip = await context.machine.execute('clipboard.read', {}); await context.machine.execute('notifications.send', { title: 'Hello', message: 'World' }); const msgs = await context.machine.execute('messages.read', { limit: 5 }); ``` See the full [Machine API Reference](/reference/machine-api) for details on each capability. --- --- url: /docs/guides/developer-database.md --- # Developer Database PIE provides each plugin (agent) with its own managed PostgreSQL database — a private schema in a shared Postgres instance, isolated by a dedicated role with restricted privileges. This is the same multi-tenant model used by platforms like Supabase. ## How It Works ``` Developer (Postico/pgAdmin) ──direct connection──► Developer DB ▲ Plugin Code (E2B sandbox) ──context.managedDb.query()──► Plugin Bridge ──► Developer DB (injects user_id) ``` Each agent gets its own isolated database schema. If you have 3 agents, you get 3 separate schemas with independent tables and credentials. **Two access paths:** 1. **Direct connection** — Use your credentials in any Postgres client (Postico, pgAdmin, psql, DBeaver) to create tables, inspect data, and design your schema. 2. **Runtime via plugin code** — Call `context.managedDb.query(sql, params)` from your plugin. PIE automatically injects the end-user's ID as a session variable so you can build per-user data isolation. ## Provisioning Your Database Databases are **auto-provisioned** — you don't need to initialize them manually. Provisioning happens automatically when: 1. You **save a plugin** that has a `database` section in its manifest (the declarative schema approach), or 2. Your plugin code calls `context.managedDb.query()` for the first time (the code-first approach) You can also provision manually via the **Database** tab in the Inspector panel, or via the API: ```bash curl -X POST https://your-pie-domain.com/api/plugins/YOUR_PLUGIN_ID/database \ -H "Authorization: Bearer YOUR_ACCESS_TOKEN" ``` Provisioning creates: * A private PostgreSQL **schema** (e.g., `dev_abc123def456...`) * A restricted **role** with access only to that schema * Login **credentials** for direct connections ## Getting Your Credentials View credentials in the **Database** tab in the Developer Portal, or fetch via the API: ```bash curl https://your-pie-domain.com/api/plugins/YOUR_PLUGIN_ID/database/credentials \ -H "Authorization: Bearer YOUR_ACCESS_TOKEN" ``` Response: ```json { "credentials": { "host": "db.example.com", "port": 5432, "database": "pie_developer", "username": "pier_abc123def456...", "password": "...", "schemaName": "dev_abc123def456...", "sslMode": "require", "connectionString": "postgresql://pier_abc123...@db.example.com:5432/pie_developer?sslmode=require" } } ``` Use the `connectionString` directly in Postico, pgAdmin, or any Postgres client. ## Connecting with Postico / pgAdmin 1. Copy your `connectionString` from the credentials endpoint 2. Paste it into your Postgres client's connection dialog 3. Your `search_path` is automatically set to your private schema — you don't need to prefix table names ## Declarative Schema Define your database tables directly in the plugin manifest's `database` section. PIE auto-creates tables and adds columns on every save — similar to how Drizzle or Prisma push schemas, but additive-only (never drops anything). ### Manifest Format ```yaml manifest: trigger: auto 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" settings: columns: id: "uuid primary key default gen_random_uuid()" pie_user_id: "text not null default current_setting('app.user_id', true)" key: "text not null" value: "jsonb" rls: "pie_user_id" tool: name: my_tool ... ``` Each key under `tables` is the table name. Each key under `columns` is the column name — the value is a raw Postgres column definition (type + constraints + default). The `rls` field (optional) names the column that holds the end-user ID. ### How Sync Works On every plugin save, PIE compares the manifest schema against the actual database: | Scenario | Action | |----------|--------| | Table in manifest but not in DB | `CREATE TABLE` | | Column in manifest but not on existing table | `ALTER TABLE ADD COLUMN` | | Table/column in DB but not in manifest | **Ignored** (no drops) | | Column exists but with a different type | **Warning** (no alter) | This is always safe — you can never lose data through the sync process. ### The `rls` Shorthand When you set `rls: "pie_user_id"` on a table, PIE automatically: 1. Runs `ALTER TABLE ... ENABLE ROW LEVEL SECURITY` 2. Runs `ALTER TABLE ... FORCE ROW LEVEL SECURITY` 3. Creates a policy: `USING (pie_user_id = current_setting('app.user_id', true)) WITH CHECK (...)` This means every query through `context.managedDb.query()` automatically filters rows to the current user — no manual RLS setup needed. ### Example: What the Platform Runs Given this manifest: ```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" rls: "pie_user_id" ``` PIE runs (on first save): ```sql CREATE TABLE IF NOT EXISTS "dev_"."notes" ( "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 ); ALTER TABLE "dev_"."notes" ENABLE ROW LEVEL SECURITY; ALTER TABLE "dev_"."notes" FORCE ROW LEVEL SECURITY; CREATE POLICY "notes_user_isolation" ON "dev_"."notes" FOR ALL USING ("pie_user_id" = current_setting('app.user_id', true)) WITH CHECK ("pie_user_id" = current_setting('app.user_id', true)); ``` On subsequent saves, only new tables/columns are created. Existing objects are left untouched. ### Removing Tables or Columns from the Manifest If you remove a table or column from the manifest, **nothing happens** — it stays in the database. The sync is additive-only. To drop a table or column, connect directly with Postico/pgAdmin and run the DDL manually. ### Manual Schema Management The declarative schema is optional. You can still create tables manually via a direct connection or `context.managedDb.query('CREATE TABLE ...')`. The manifest approach simply automates the common case. ## Creating Tables You can create tables in two ways: declaratively via the manifest (recommended), or manually. ### Declarative (Manifest) Add tables to the `database.tables` section in your manifest (see above). They're created automatically on save. ### Manual (SQL) Connect with your credentials and create tables normally: ```sql CREATE TABLE users ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), external_user_id TEXT NOT NULL, display_name TEXT, settings JSONB DEFAULT '{}', created_at TIMESTAMPTZ DEFAULT NOW() ); CREATE TABLE notes ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), user_id UUID REFERENCES users(id), title TEXT NOT NULL, content TEXT, created_at TIMESTAMPTZ DEFAULT NOW() ); ``` ## Using `context.managedDb.query()` in Plugins Call `context.managedDb.query()` from your plugin code. Unlike `context.db.query()` (which is for external databases), the managed DB requires no connection object — credentials are handled automatically. ### Basic Queries ```javascript async function handler(input, context) { // INSERT await context.managedDb.query( 'INSERT INTO notes (user_id, title, content) VALUES ($1, $2, $3)', [input.userId, input.title, input.content] ); // SELECT const result = await context.managedDb.query( 'SELECT * FROM notes WHERE user_id = $1 ORDER BY created_at DESC LIMIT 10', [input.userId] ); return { notes: result.rows }; } ``` ### Query Response ```javascript { rows: [{ id: '...', title: '...', content: '...' }], columns: ['id', 'title', 'content'], rowCount: 1, truncated: false, executionTimeMs: 12 } ``` ### Options ```javascript const result = await context.managedDb.query( 'SELECT * FROM large_table', [], { timeoutMs: 10000, maxRows: 5000 } ); ``` | Option | Default | Max | Description | |--------|---------|-----|-------------| | `timeoutMs` | 30000 | 30000 | Statement timeout in milliseconds | | `maxRows` | 1000 | 10000 | Maximum rows returned | ## User Context Every query executed through `context.managedDb.query()` has the **end-user's ID** injected as a PostgreSQL session variable. Your plugin code can access it, and more importantly, you can use it in Row-Level Security policies. The following session variables are set on every query: | Variable | Value | Access with | |----------|-------|-------------| | `app.user_id` | The end-user's PIE user ID | `current_setting('app.user_id')` | | `app.plugin_id` | Your plugin's ID | `current_setting('app.plugin_id')` | ### Using User ID in Queries ```javascript async function handler(input, context) { // context.user.id is the same value as app.user_id const result = await context.managedDb.query( 'SELECT * FROM notes WHERE external_user_id = $1', [context.user.id] ); return { notes: result.rows }; } ``` ## Row-Level Security (RLS) Patterns For per-user data isolation, you can create RLS policies that use the injected `app.user_id` session variable. This way, even if your plugin code has a bug, users can never see each other's data. ::: tip Declarative RLS If you use the manifest's `database.tables` section, just add `rls: "pie_user_id"` to your table and the policy is created automatically. The manual setup below is only needed if you're managing tables outside the manifest. ::: ### Setup ```sql -- 1. Store the PIE user ID on your rows CREATE TABLE user_data ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), pie_user_id TEXT NOT NULL, data JSONB, created_at TIMESTAMPTZ DEFAULT NOW() ); -- 2. Enable RLS ALTER TABLE user_data ENABLE ROW LEVEL SECURITY; -- 3. Create a policy that restricts access to the current user's rows CREATE POLICY user_data_isolation ON user_data FOR ALL USING (pie_user_id = current_setting('app.user_id', true)) WITH CHECK (pie_user_id = current_setting('app.user_id', true)); -- 4. Force RLS for the table owner too (important!) ALTER TABLE user_data FORCE ROW LEVEL SECURITY; ``` With this in place, every query through `context.managedDb.query()` automatically filters to the current user's data: ```javascript // This only returns rows belonging to the current user const result = await context.managedDb.query('SELECT * FROM user_data'); ``` ### Auto-Populating User ID on Insert Use a trigger or default value to automatically set the user ID: ```sql ALTER TABLE user_data ALTER COLUMN pie_user_id SET DEFAULT current_setting('app.user_id', true); ``` Now inserts don't even need to specify the user ID: ```javascript await context.managedDb.query( 'INSERT INTO user_data (data) VALUES ($1)', [{ key: 'value' }] ); ``` ## Limits | Limit | Value | |-------|-------| | Statement timeout | 30 seconds | | Max rows per query | 10,000 | | Max response size | 5 MB | | Max concurrent connections (direct) | 10 | ## Security * Your schema is **completely isolated**. Your role cannot access any other developer's schema. * Your role has `NOSUPERUSER`, `NOCREATEDB`, `NOCREATEROLE`, `NOREPLICATION` — no privilege escalation is possible. * Runtime queries run on **fresh connections** per request, preventing session-state leakage. * The `public` schema is locked down — `REVOKE ALL ON SCHEMA public FROM PUBLIC` is enforced on the developer database. * Credentials are encrypted at rest using AES-256-GCM. ## Password Rotation If you need to regenerate your database password (e.g., if credentials are leaked), use the **Regenerate Password** button in the Database tab, or call the API: ```bash curl -X POST https://your-pie-domain.com/api/plugins/YOUR_PLUGIN_ID/database/regenerate-password \ -H "Authorization: Bearer YOUR_ACCESS_TOKEN" ``` This immediately invalidates the old password and returns new credentials. ## Usage Stats View table counts and storage in the Database tab, or via the API: ```bash curl https://your-pie-domain.com/api/plugins/YOUR_PLUGIN_ID/database/stats \ -H "Authorization: Bearer YOUR_ACCESS_TOKEN" ``` Response: ```json { "stats": { "schemaName": "dev_abc123...", "tableCount": 3, "totalSizeBytes": 81920, "tables": [ { "name": "users", "sizeBytes": 40960 }, { "name": "notes", "sizeBytes": 32768 }, { "name": "settings", "sizeBytes": 8192 } ] } } ``` ## Comparison: `context.db.query()` vs `context.managedDb.query()` | Feature | `context.db.query()` | `context.managedDb.query()` | |---------|---------------------|-----------------------------| | Database | Any external Postgres | PIE-managed developer DB | | Connection | You provide credentials | Automatic | | Access | Read-only (SELECT/WITH/EXPLAIN) | Full CRUD | | User context | None | `app.user_id` auto-injected | | Use case | Querying user's own databases | Storing plugin data | --- --- url: /docs/examples/email-labeler.md --- # Email Auto-Labeler This example shows how to build an automation that automatically categorizes and labels emails using AI. It demonstrates lifecycle hooks for real-time processing. ## Overview * **Features:** `oauth` + `automation` * **Trigger:** Webhook (real-time) + Cron (fallback) * **OAuth:** Google (Gmail API) * **Lifecycle hooks:** `onConnect` (sets up Gmail watch), `onWebhook` (processes notifications) ## Manifest ```json { "automation": { "triggers": [ { "type": "webhook", "description": "Real-time processing via Gmail push notifications" }, { "type": "cron", "default": "*/15 * * * *", "description": "Fallback check every 15 minutes" } ], "timeout": 120, "onWebhook": true }, "oauth": { "provider": "google", "scopes": [ "https://www.googleapis.com/auth/gmail.modify", "https://www.googleapis.com/auth/gmail.labels" ], "clientIdSecret": "GOOGLE_CLIENT_ID", "clientSecretSecret": "GOOGLE_CLIENT_SECRET" }, "developerSecrets": { "GOOGLE_CLIENT_ID": { "required": true }, "GOOGLE_CLIENT_SECRET": { "required": true }, "GMAIL_PUBSUB_TOPIC": { "description": "Google Cloud Pub/Sub topic for push notifications", "required": false } } } ``` ## Code ```javascript const GMAIL_API = 'https://gmail.googleapis.com/gmail/v1/users/me'; const LABELS = [ 'Urgent', 'Action Required', 'Awaiting Reply', 'Reference', 'Newsletter', 'Social', 'Calendar', 'Travel', 'Financial Records', 'Shipping', 'Medical', 'Family', 'Recruiting', 'Cold Pitch', 'Marketing', 'Intro Request', 'Transactional' ]; let labelCache = {}; function getHeader(headers, name) { const header = headers.find(h => h.name.toLowerCase() === name.toLowerCase()); return header?.value || ''; } async function getOrCreateLabel(context, labelName) { if (labelCache[labelName]) return labelCache[labelName]; const listResp = await context.oauth.fetch(`${GMAIL_API}/labels`); const labelsData = JSON.parse(listResp.body); const existing = labelsData.labels?.find(l => l.name === labelName); if (existing) { labelCache[labelName] = existing.id; return existing.id; } const createResp = await context.oauth.fetch(`${GMAIL_API}/labels`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: labelName, labelListVisibility: 'labelShow', messageListVisibility: 'show', }), }); const newLabel = JSON.parse(createResp.body); labelCache[labelName] = newLabel.id; return newLabel.id; } async function processEmail(context, messageId) { const msgResp = await context.oauth.fetch( `${GMAIL_API}/messages/${messageId}?format=metadata&metadataHeaders=From&metadataHeaders=Subject` ); if (!msgResp.ok) return null; const msgData = JSON.parse(msgResp.body); const headers = msgData.payload?.headers || []; const from = getHeader(headers, 'From'); const subject = getHeader(headers, 'Subject'); const snippet = msgData.snippet || ''; // Skip if already labeled const existingLabels = msgData.labelIds || []; if (LABELS.some(l => existingLabels.includes(l))) return null; // Classify with AI const classification = await context.ai.analyze({ prompt: `Classify this email into ONE category: ${LABELS.join(', ')} Return JSON: {"label": "CategoryName"}`, data: { from, subject, snippet: snippet.substring(0, 200) } }); let labelName = classification?.label || 'Reference'; if (!LABELS.includes(labelName)) labelName = 'Reference'; const labelId = await getOrCreateLabel(context, labelName); await context.oauth.fetch(`${GMAIL_API}/messages/${messageId}/modify`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ addLabelIds: [labelId] }), }); return { messageId, from, subject, label: labelName }; } /** * onConnect - Called after OAuth connection * Sets up Gmail push notifications via Pub/Sub */ async function onConnect(input, context) { const topic = context.secrets.GMAIL_PUBSUB_TOPIC; if (!topic) { return { success: true, watch: false, reason: 'No Pub/Sub topic configured' }; } const watchResp = await context.oauth.fetch(`${GMAIL_API}/watch`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ topicName: topic, labelIds: ['INBOX'], }), }); if (!watchResp.ok) { throw new Error(`Gmail watch failed: ${watchResp.status}`); } await context.notify('Email Auto-Labeler is now active! New emails will be labeled automatically.', { title: 'Auto-Labeler Connected' }); return { success: true, watch: true }; } /** * onWebhook - Called when Pub/Sub pushes a notification */ async function onWebhook(input, context) { const payload = input.triggerData; // Decode Pub/Sub message let notification; if (payload.message?.data) { notification = JSON.parse(atob(payload.message.data)); } else { notification = payload; } const { historyId, emailAddress } = notification; // Verify this is for the right user const connectionInfo = await context.oauth.getConnectionInfo(); if (emailAddress && connectionInfo.email !== emailAddress) { return { success: true, skipped: true, reason: 'Different user' }; } // Fetch history for new messages const historyResp = await context.oauth.fetch( `${GMAIL_API}/history?startHistoryId=${historyId}&historyTypes=messageAdded` ); if (!historyResp.ok) { // History too old, process recent inbox const recentResp = await context.oauth.fetch(`${GMAIL_API}/messages?labelIds=INBOX&maxResults=5`); const recentData = JSON.parse(recentResp.body); let processed = 0; let urgentEmails = []; for (const msg of (recentData.messages || [])) { const result = await processEmail(context, msg.id); if (result) { processed++; if (result.label === 'Urgent') urgentEmails.push(result); } } if (urgentEmails.length > 0) { const urgentList = urgentEmails.map(e => `- **${e.subject}** from ${e.from}`).join('\n'); await context.notify(`**${urgentEmails.length} urgent email(s):**\n\n${urgentList}`, { urgent: true, title: 'Urgent Emails' }); } return { success: true, processed }; } const historyData = JSON.parse(historyResp.body); let processed = 0; let urgentEmails = []; for (const record of (historyData.history || [])) { for (const added of (record.messagesAdded || [])) { const result = await processEmail(context, added.message.id); if (result) { processed++; if (result.label === 'Urgent') urgentEmails.push(result); } } } if (urgentEmails.length > 0) { const urgentList = urgentEmails.map(e => `- **${e.subject}** from ${e.from}`).join('\n'); await context.notify(`**${urgentEmails.length} urgent email(s):**\n\n${urgentList}`, { urgent: true, title: 'Urgent Emails' }); } return { success: true, processed }; } /** * handler - Fallback for cron/manual triggers */ async function handler(input, context) { if (!await context.oauth.isConnected()) { return { success: false, reason: 'Gmail not connected' }; } const sinceTime = input.lastRunAt || (Date.now() - 15 * 60 * 1000); const sinceSeconds = Math.floor(sinceTime / 1000); const searchResp = await context.oauth.fetch( `${GMAIL_API}/messages?q=after:${sinceSeconds}&maxResults=20` ); const searchData = JSON.parse(searchResp.body); if (!searchData.messages) return { success: true, labeled: 0 }; let labeledCount = 0; let urgentEmails = []; for (const msg of searchData.messages) { const result = await processEmail(context, msg.id); if (result) { labeledCount++; if (result.label === 'Urgent') urgentEmails.push(result); } } if (urgentEmails.length > 0) { const urgentList = urgentEmails.map(e => `- **${e.subject}** from ${e.from}`).join('\n'); await context.notify(`**${urgentEmails.length} urgent email(s):**\n\n${urgentList}`, { urgent: true, title: 'Urgent Emails' }); } return { success: true, labeled: labeledCount }; } module.exports = { handler, onConnect, onWebhook }; ``` ## Key Concepts ### Lifecycle Hooks This agent uses three handlers: | Handler | When it runs | Purpose | |---------|--------------|---------| | `onConnect` | After OAuth | Sets up Gmail push notifications | | `onWebhook` | When Pub/Sub pushes | Processes new emails in real-time | | `handler` | Cron schedule | Fallback if webhooks miss something | ### Setting Up Gmail Watch The `onConnect` handler calls Gmail's watch API: ```javascript await context.oauth.fetch(`${GMAIL_API}/watch`, { method: 'POST', body: JSON.stringify({ topicName: context.secrets.GMAIL_PUBSUB_TOPIC, labelIds: ['INBOX'], }), }); ``` This tells Gmail to push notifications to your Pub/Sub topic. ### Processing Webhook Payloads Pub/Sub messages are base64 encoded: ```javascript const notification = JSON.parse(atob(payload.message.data)); const { historyId, emailAddress } = notification; ``` ### User Verification Since the webhook URL is shared, verify the notification is for the right user: ```javascript const connectionInfo = await context.oauth.getConnectionInfo(); if (emailAddress && connectionInfo.email !== emailAddress) { return { skipped: true }; // Wrong user } ``` ## Setup Requirements To enable real-time notifications: 1. Create a Google Cloud Pub/Sub topic 2. Configure the topic to push to: `https://your-domain/api/webhooks/plugin/{pluginId}` 3. Set `GMAIL_PUBSUB_TOPIC` in your agent secrets Without Pub/Sub, the agent still works via the cron fallback (every 15 minutes). ## Testing 1. Install the automation from the Agents page 2. Connect your Gmail account (triggers `onConnect`) 3. Send yourself a test email 4. Watch for real-time labeling (if Pub/Sub configured) or wait for cron --- --- url: /docs/guides/error-handling.md --- # Error Handling Proper error handling makes your agents reliable and helps users understand what went wrong. ## Return Values ### Success Return the data directly: ```js return { temperature: 72, condition: 'Sunny', city: 'London', }; ``` ### Error Return an error object: ```js return { error: true, message: 'City not found', }; ``` ### Requires Authentication For OAuth agents when not connected: ```js return { error: true, message: 'Please connect your account first', requiresAuth: true, }; ``` ## Error Types ### Validation Errors Check input parameters: ```js async function handler(input, context) { const { city } = input; if (!city) { return { error: true, message: 'City is required' }; } if (typeof city !== 'string') { return { error: true, message: 'City must be a string' }; } // Continue... } ``` ### Missing Secrets Check for required secrets: ```js const apiKey = context.secrets.API_KEY; if (!apiKey) { return { error: true, message: 'API key not configured. Please add your API key in plugin settings.' }; } ``` ### API Errors Handle HTTP errors: ```js const response = await context.fetch(url); if (!response.ok) { // Try to get error details from response let errorMessage = `Request failed with status ${response.status}`; try { const errorData = JSON.parse(response.body); if (errorData.message) { errorMessage = errorData.message; } } catch { // Response wasn't JSON } return { error: true, message: errorMessage }; } ``` ### Network Errors Catch exceptions: ```js try { const response = await context.fetch(url); // ... } catch (error) { return { error: true, message: error.message || 'Network request failed' }; } ``` ## Full Pattern ```js async function handler(input, context) { // 1. Validate input const { action, query } = input; if (!action) { return { error: true, message: 'Action is required' }; } // 2. Check OAuth (for connectors) if (!await context.oauth.isConnected()) { return { error: true, requiresAuth: true }; } // 3. Check secrets (for API-key tools) const apiKey = context.secrets.API_KEY; if (!apiKey) { return { error: true, message: 'API key not configured' }; } // 4. Make request with error handling try { const response = await context.fetch(url); // 5. Handle HTTP errors if (!response.ok) { return handleApiError(response); } // 6. Parse response const data = JSON.parse(response.body); // 7. Return success return { success: true, data }; } catch (error) { // 8. Handle exceptions return { error: true, message: error.message || 'Request failed' }; } } function handleApiError(response) { const statusMessages = { 400: 'Invalid request', 401: 'Authentication failed', 403: 'Access denied', 404: 'Not found', 429: 'Rate limited - please try again later', 500: 'Server error', }; const message = statusMessages[response.status] || `Error: ${response.status}`; return { error: true, message }; } ``` ## Helpful Error Messages ### Bad ```js return { error: true, message: 'Error' }; return { error: true, message: 'Failed' }; return { error: true, message: response.status }; ``` ### Good ```js return { error: true, message: 'City "Londn" not found. Did you mean "London"?' }; return { error: true, message: 'Weather API rate limit exceeded. Try again in 1 minute.' }; return { error: true, message: 'Invalid API key. Please check your configuration.' }; ``` ## Error Recovery When possible, suggest fixes: ```js if (response.status === 429) { return { error: true, message: 'Rate limited. Please wait a moment before trying again.', retryAfter: 60, }; } if (response.status === 401) { return { error: true, message: 'Session expired. Please reconnect your account.', requiresAuth: true, }; } ``` ## Logging Use console.log for debugging (output may be limited): ```js try { console.log('Making request to:', url); const response = await context.fetch(url); console.log('Response status:', response.status); // ... } catch (error) { console.error('Request failed:', error.message); throw error; } ``` ## Testing Errors Test your error handling: 1. Missing required parameters 2. Invalid parameter types 3. Missing secrets 4. API returning errors (4xx, 5xx) 5. Network timeouts 6. Invalid JSON responses 7. OAuth not connected --- --- url: /docs/examples/gmail-connector.md --- # Gmail Connector Example A complete OAuth connector for Gmail that demonstrates the full connector pattern. ## Overview * **Type:** Connector * **OAuth Provider:** Google * **Capabilities:** Search, Read, Send, Archive, Labels, Trash ## Full Code ```js /** * Gmail Connector - Search, read, send, and manage emails * * Demonstrates: * - OAuth authentication via context.oauth * - Making authenticated API requests * - Multi-action tool pattern * - Handling Gmail API responses */ const GMAIL_API = 'https://gmail.googleapis.com/gmail/v1/users/me'; // Helper: Decode base64url (used by Gmail) function decodeBase64Url(data) { const base64 = data.replace(/-/g, '+').replace(/_/g, '/'); return atob(base64); } // Helper: Encode to base64url (for sending) function encodeBase64Url(str) { return btoa(str) .replace(/\+/g, '-') .replace(/\//g, '_') .replace(/=+$/, ''); } // Helper: Get email header value function getHeader(headers, name) { const header = headers.find(h => h.name.toLowerCase() === name.toLowerCase()); return header?.value || ''; } // Helper: Extract email body from payload function extractBody(payload) { if (!payload) return { text: '' }; let text = ''; let html = ''; if (payload.body?.data) { text = decodeBase64Url(payload.body.data); } if (payload.parts) { for (const part of payload.parts) { if (part.mimeType === 'text/plain' && part.body?.data) { text = decodeBase64Url(part.body.data); } else if (part.mimeType === 'text/html' && part.body?.data) { html = decodeBase64Url(part.body.data); } } } return { text, html }; } async function handler(input, context) { const { action, ...params } = input; // Check OAuth connection const isConnected = await context.oauth.isConnected(); if (!isConnected) { return { error: true, message: 'Gmail not connected. Please connect your Google account.', requiresAuth: true }; } try { switch (action) { case 'search': return await searchEmails(params, context); case 'read': return await readEmail(params, context); case 'send': return await sendEmail(params, context); case 'archive': return await archiveEmail(params, context); case 'listLabels': return await listLabels(context); case 'trash': return await trashEmail(params, context); default: return { error: true, message: `Unknown action: ${action}. Valid: search, read, send, archive, listLabels, trash` }; } } catch (error) { return { error: true, message: error.message || 'Gmail operation failed' }; } } async function searchEmails({ query, maxResults = 10 }, context) { if (!query) { return { error: true, message: 'Search query is required' }; } // Search for message IDs const searchUrl = `${GMAIL_API}/messages?q=${encodeURIComponent(query)}&maxResults=${maxResults}`; const searchResp = await context.oauth.fetch(searchUrl); if (!searchResp.ok) { throw new Error(`Gmail search failed: ${searchResp.status}`); } const searchData = JSON.parse(searchResp.body); if (!searchData.messages?.length) { return { success: true, action: 'search', query, count: 0, emails: [] }; } // Fetch details for each message const emails = []; for (const msg of searchData.messages.slice(0, maxResults)) { const msgUrl = `${GMAIL_API}/messages/${msg.id}?format=metadata&metadataHeaders=From&metadataHeaders=To&metadataHeaders=Subject&metadataHeaders=Date`; const msgResp = await context.oauth.fetch(msgUrl); if (msgResp.ok) { const msgData = JSON.parse(msgResp.body); const headers = msgData.payload?.headers || []; emails.push({ id: msgData.id, threadId: msgData.threadId, from: getHeader(headers, 'From'), to: getHeader(headers, 'To'), subject: getHeader(headers, 'Subject'), date: getHeader(headers, 'Date'), snippet: msgData.snippet, }); } } return { success: true, action: 'search', query, count: emails.length, emails }; } async function readEmail({ messageId }, context) { if (!messageId) { return { error: true, message: 'Message ID is required' }; } const resp = await context.oauth.fetch( `${GMAIL_API}/messages/${messageId}?format=full` ); if (!resp.ok) { throw new Error(`Failed to read email: ${resp.status}`); } const msg = JSON.parse(resp.body); const headers = msg.payload?.headers || []; const { text, html } = extractBody(msg.payload); return { success: true, action: 'read', email: { id: msg.id, threadId: msg.threadId, from: getHeader(headers, 'From'), to: getHeader(headers, 'To'), subject: getHeader(headers, 'Subject'), date: getHeader(headers, 'Date'), body: text, bodyHtml: html, labels: msg.labelIds || [], }, }; } async function sendEmail({ to, subject, body, cc, bcc, isHtml }, context) { if (!to || !subject || !body) { return { error: true, message: 'to, subject, and body are required' }; } // Build RFC 2822 email const contentType = isHtml ? 'text/html' : 'text/plain'; const headers = [ `To: ${to}`, `Subject: ${subject}`, `Content-Type: ${contentType}; charset=utf-8`, ]; if (cc) headers.push(`Cc: ${cc}`); if (bcc) headers.push(`Bcc: ${bcc}`); const email = `${headers.join('\r\n')}\r\n\r\n${body}`; const encodedEmail = encodeBase64Url(email); const resp = await context.oauth.fetch( `${GMAIL_API}/messages/send`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ raw: encodedEmail }), } ); if (!resp.ok) { throw new Error(`Failed to send email: ${resp.status}`); } const result = JSON.parse(resp.body); return { success: true, action: 'send', message: 'Email sent successfully', id: result.id, threadId: result.threadId }; } async function archiveEmail({ messageId }, context) { if (!messageId) { return { error: true, message: 'Message ID is required' }; } const resp = await context.oauth.fetch( `${GMAIL_API}/messages/${messageId}/modify`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ removeLabelIds: ['INBOX'] }), } ); if (!resp.ok) { throw new Error(`Failed to archive email: ${resp.status}`); } return { success: true, action: 'archive', message: 'Email archived', messageId }; } async function listLabels(context) { const resp = await context.oauth.fetch(`${GMAIL_API}/labels`); if (!resp.ok) { throw new Error(`Failed to list labels: ${resp.status}`); } const data = JSON.parse(resp.body); return { success: true, action: 'listLabels', count: data.labels.length, labels: data.labels }; } async function trashEmail({ messageId }, context) { if (!messageId) { return { error: true, message: 'Message ID is required' }; } const resp = await context.oauth.fetch( `${GMAIL_API}/messages/${messageId}/trash`, { method: 'POST' } ); if (!resp.ok) { throw new Error(`Failed to trash email: ${resp.status}`); } return { success: true, action: 'trash', message: 'Email moved to trash', messageId }; } module.exports = { handler }; ``` ## Manifest ```json { "trigger": "auto", "oauth": { "provider": "google", "scopes": [ "https://www.googleapis.com/auth/gmail.readonly", "https://www.googleapis.com/auth/gmail.send", "https://www.googleapis.com/auth/gmail.modify", "https://www.googleapis.com/auth/gmail.labels", "https://www.googleapis.com/auth/userinfo.email" ], "clientIdSecret": "GOOGLE_CLIENT_ID", "clientSecretSecret": "GOOGLE_CLIENT_SECRET" }, "developerSecrets": { "GOOGLE_CLIENT_ID": { "description": "Google OAuth Client ID", "required": true }, "GOOGLE_CLIENT_SECRET": { "description": "Google OAuth Client Secret", "required": true } }, "tool": { "name": "gmail", "description": "Access Gmail to search, read, send, and manage emails. Use when users want to interact with their email.", "parameters": { "type": "object", "properties": { "action": { "type": "string", "enum": ["search", "read", "send", "archive", "listLabels", "trash"], "description": "The Gmail action to perform" }, "query": { "type": "string", "description": "For search: Gmail search query (e.g., 'from:john subject:meeting')" }, "maxResults": { "type": "integer", "description": "For search: Max emails to return (default: 10)" }, "messageId": { "type": "string", "description": "For read/archive/trash: The email message ID" }, "to": { "type": "string", "description": "For send: Recipient email address" }, "subject": { "type": "string", "description": "For send: Email subject" }, "body": { "type": "string", "description": "For send: Email body" } }, "required": ["action"] } } } ``` ## Setup 1. **Create Google OAuth App** * Go to [Google Cloud Console](https://console.cloud.google.com/) * Create new project * Enable Gmail API * Create OAuth 2.0 credentials * Add redirect URI: `https://your-pie-domain.com/api/oauth/plugin/{pluginId}/callback` 2. **Create Agent in PIE** * Type: Connector * Paste code and manifest 3. **Add OAuth Credentials** * Set `GOOGLE_CLIENT_ID` and `GOOGLE_CLIENT_SECRET` as developer secrets 4. **Connect** * Users click "Connect" to authorize ## Key Patterns ### OAuth Check Always check connection first: ```js if (!await context.oauth.isConnected()) { return { error: true, requiresAuth: true }; } ``` ### Authenticated Requests PIE adds the Bearer token automatically: ```js const resp = await context.oauth.fetch(url); ``` ### Multi-Action Pattern Handle multiple operations in one tool: ```js switch (action) { case 'search': return await searchEmails(params, context); case 'read': return await readEmail(params, context); // ... } ``` --- --- url: /docs/guides/handling-secrets.md --- # Handling Secrets Secrets let you store sensitive information like API keys securely. ## Defining Secrets In your manifest, declare what secrets your agent needs: ```json { "developerSecrets": { "API_KEY": { "description": "Your API key from example.com", "required": true }, "WEBHOOK_SECRET": { "description": "Optional webhook signing secret", "required": false } } } ``` ## Accessing Secrets In your handler, use `context.secrets`: ```js async function handler(input, context) { const apiKey = context.secrets.API_KEY; if (!apiKey) { return { error: true, message: 'API key not configured' }; } // Use the secret const response = await context.fetch('https://api.example.com/data', { headers: { 'Authorization': `Bearer ${apiKey}`, }, }); // ... } ``` ## Security ### Encryption at Rest Secrets are encrypted using AES-256-GCM before storage. Even if the database is compromised, secrets remain protected. ### Never Log Secrets Don't log or return secrets in responses: ```js // BAD - exposes secret console.log('Using key:', context.secrets.API_KEY); return { apiKey: context.secrets.API_KEY }; // GOOD - secret stays private console.log('Making authenticated request...'); ``` ### Sandbox Isolation Your agent code runs in an isolated sandbox. It cannot: * Access the filesystem * Make network requests without `context.fetch()` * Access other agents' secrets ## For Agent Developers vs Users ### Developer Secrets Defined in `developerSecrets` - you (the agent author) provide these: ```json { "developerSecrets": { "WEATHER_API_KEY": { "description": "API key for weather service", "required": true } } } ``` These are secrets YOU configure when publishing your agent. ### OAuth vs Secrets For services with OAuth: * Use `oauth` config in manifest * PIE handles tokens automatically * Use `context.oauth.fetch()` For services with API keys: * Use `developerSecrets` in manifest * Store the API key as a secret * Use `context.fetch()` with the key ## Example: Weather Tool **Manifest:** ```json { "trigger": "auto", "developerSecrets": { "WEATHER_API_KEY": { "description": "API key from weatherapi.com", "required": true } }, "tool": { "name": "get_weather", "description": "Get weather for a city", "parameters": { ... } } } ``` **Code:** ```js async function handler(input, context) { const apiKey = context.secrets.WEATHER_API_KEY; if (!apiKey) { return { error: true, message: 'Weather API key not configured' }; } const response = await context.fetch( `https://api.weatherapi.com/v1/current.json?key=${apiKey}&q=${input.city}` ); // ... } ``` ## Best Practices 1. **Check for missing secrets** - Don't assume secrets exist 2. **Never expose secrets** - Don't log, return, or display them 3. **Use descriptive names** - `WEATHER_API_KEY` not `KEY1` 4. **Document requirements** - Explain what each secret is for 5. **Mark required correctly** - Only mark as required if truly needed --- --- url: /docs/reference/machine-api.md --- # Machine API Reference The `context.machine` API is available to plugins that declare `machineCapabilities` in their manifest. It allows plugins to interact with the user's connected machine through PIE Connect. ## context.machine.isOnline() Check if the user has at least one online machine. **Returns:** `Promise` ```javascript const online = await context.machine.isOnline(); ``` ## context.machine.list() List all connected machines for the current user. **Returns:** `Promise>` ```javascript const machines = await context.machine.list(); ``` ## context.machine.execute(capability, params) Execute a command on the user's machine. The plugin's manifest must declare the capability, and the user must have approved it at install time. **Parameters:** * `capability` (string) — The capability to execute (e.g., `"machine.info"`) * `params` (object) — Parameters for the capability **Returns:** `Promise` ```javascript const result = await context.machine.execute('machine.info', {}); ``` ## context.machine.upload(macPath) Convenience method to upload a file from the Mac to PIE cloud storage. Equivalent to `execute('file.transfer', { action: 'upload', path: macPath })`. **Parameters:** * `macPath` (string) — File path on the Mac (supports `~` for home directory) **Returns:** `Promise<{ action, fileId, url, size, filename, mimeType, sourcePath }>` ```javascript const uploaded = await context.machine.upload('~/Documents/report.pdf'); console.log(uploaded.fileId, uploaded.url); // file ID and view URL ``` ## context.machine.download(urlOrGcsPath, macSavePath) Convenience method to download a file from the cloud to the Mac. Equivalent to `execute('file.transfer', { action: 'download', ... })`. **Parameters:** * `urlOrGcsPath` (string) — A URL to download from, or a GCS path from a previous upload * `macSavePath` (string) — Path on the Mac to save the file **Returns:** `Promise<{ action, savedTo, size, filename }>` ```javascript await context.machine.download('https://example.com/file.pdf', '~/Downloads/file.pdf'); ``` *** ## Capabilities Reference ### machine.info Read basic machine information. **Risk:** Low **Platforms:** macOS, Linux, Windows **Parameters:** None **Returns:** ```json { "hostname": "Jakes-MacBook-Pro", "platform": "darwin", "arch": "arm64", "osType": "Darwin", "osRelease": "24.0.0", "osVersion": "Darwin Kernel Version 24.0.0", "uptime": 123456, "totalMemory": 17179869184, "freeMemory": 8589934592, "cpus": 10, "nodeVersion": "v22.0.0", "homeDir": "/Users/jake", "username": "jake" } ``` ### clipboard.read Read the current clipboard contents. **Risk:** Medium **Platforms:** macOS, Linux, Windows **Parameters:** None **Returns:** ```json { "content": "Hello, world!", "length": 13 } ``` ### clipboard.write Write text to the system clipboard. **Risk:** Medium **Platforms:** macOS, Linux, Windows **Parameters:** | Name | Type | Description | |---|---|---| | `text` | string | The text to write to the clipboard (required) | **Returns:** ```json { "written": true, "length": 13 } ``` ### notifications.send Send a desktop notification. **Risk:** Low **Platforms:** macOS, Linux, Windows **Parameters:** | Name | Type | Description | |---|---|---| | `title` | string | Notification title (default: "PIE") | | `message` | string | Notification body (default: "Notification from PIE") | **Returns:** ```json { "sent": true, "title": "PIE", "message": "Hello from PIE!" } ``` ### messages.read Read iMessages from the Mac's Messages database. **macOS only.** Requires Full Disk Access permission. **Risk:** High **Parameters:** | Name | Type | Description | |---|---|---| | `limit` | number | Max messages to return (1-100, default: 5) | | `contact` | string | Filter by contact (partial match) | | `after` | string | ISO date string — only messages after this date | **Returns:** ```json { "messages": [ { "text": "Hey, are you free tonight?", "fromMe": false, "date": "2025-01-15 14:30:00", "sender": "+15551234567" } ], "count": 1 } ``` ### screenshot.capture Capture a screenshot of the Mac screen. **Risk:** High **Platforms:** macOS **Parameters:** | Name | Type | Description | |---|---|---| | `quality` | number | JPEG quality (1-100, default: 60) | | `maxWidth` | number | Max image width in pixels (default: 1280) | **Returns:** ```json { "image": "data:image/jpeg;base64,...", "width": 1280, "height": 800 } ``` ### desktop.control Control mouse and keyboard on the Mac. **macOS only.** Requires Accessibility permission. **Risk:** Critical **Parameters:** | Name | Type | Description | |---|---|---| | `action` | string | Required. One of: `click`, `double_click`, `right_click`, `move`, `drag`, `scroll`, `type`, `press_key`, `info`, `open_app` | | `x`, `y` | number | Coordinates for click/move/scroll actions | | `startX`, `startY`, `endX`, `endY` | number | Coordinates for drag action | | `deltaY` | number | Scroll amount for scroll action | | `text` | string | Text for type action | | `key` | string | Key name or combo for press\_key (e.g., `"return"`, `"cmd+c"`) | | `modifiers` | string\[] | Optional modifiers for press\_key (`["cmd", "shift"]`) | | `name` | string | App name for open\_app action | **Returns:** Object with `action`, `success`, and action-specific fields. ### shell.run Execute shell commands on the machine. **Risk:** Critical **Platforms:** macOS, Linux, Windows **Parameters:** | Name | Type | Description | |---|---|---| | `command` | string | The shell command to execute (required) | | `cwd` | string | Working directory (default: user home) | | `timeout` | number | Timeout in ms (default: 30000, max: 120000) | **Returns:** ```json { "exitCode": 0, "stdout": "hello world\n", "stderr": "", "command": "echo hello world", "cwd": "/Users/jake" } ``` ### filesystem Read, write, list, search, move, copy, and delete files. **Risk:** High **Platforms:** macOS, Linux, Windows **Parameters:** | Name | Type | Description | |---|---|---| | `action` | string | Required. One of: `read`, `write`, `list`, `search`, `move`, `copy`, `delete`, `stat` | **Action: read** — Read a file's contents (max 5MB) | Name | Type | Description | |---|---|---| | `path` | string | File path (supports `~` for home dir) | | `encoding` | string | Encoding (default: `utf-8`) | **Action: write** — Write or append to a file | Name | Type | Description | |---|---|---| | `path` | string | File path | | `content` | string | Content to write | | `append` | boolean | Append instead of overwrite (default: false) | **Action: list** — List directory contents | Name | Type | Description | |---|---|---| | `path` | string | Directory path | | `recursive` | boolean | Recurse into subdirectories (default: false) | | `glob` | string | Filter by name pattern | | `showHidden` | boolean | Include hidden files (default: false) | **Action: search** — Search file contents | Name | Type | Description | |---|---|---| | `path` | string | Directory to search in | | `pattern` | string | Search pattern | | `maxResults` | number | Max results (default: 200) | **Action: move** — Move or rename a file | Name | Type | Description | |---|---|---| | `source` | string | Source path | | `destination` | string | Destination path | **Action: copy** — Copy a file or directory | Name | Type | Description | |---|---|---| | `source` | string | Source path | | `destination` | string | Destination path | **Action: delete** — Delete a file or directory | Name | Type | Description | |---|---|---| | `path` | string | Path to delete | | `recursive` | boolean | Required for directories (default: false) | **Action: stat** — Get file metadata | Name | Type | Description | |---|---|---| | `path` | string | File or directory path | **Returns (stat example):** ```json { "action": "stat", "path": "/Users/jake", "exists": true, "type": "directory", "size": 1024, "sizeHuman": "1.0KB", "created": "2024-01-01T00:00:00.000Z", "modified": "2025-03-14T12:00:00.000Z", "permissions": "755" } ``` ### apps.automate Automate macOS applications via scoped AppleScript. The script is always executed inside a `tell application` block for the specified app. Timeout is **30 seconds**. **Risk:** High **Platforms:** macOS **Parameters:** | Name | Type | Description | |---|---|---| | `app` | string | Application name (e.g., `"Spotify"`, `"Finder"`, `"Calendar"`) | | `script` | string | AppleScript commands to run inside `tell application` block | **Safety:** Scripts cannot contain the following patterns (use `shell.run` or `filesystem` instead): `do shell script`, `run script`, `system attribute`, `path to startup disk`, `do script`, `POSIX file`, `call method`, `store script`, `load script`, `scripting additions`. **Returns:** ```json { "app": "Spotify", "output": "Playing", "success": true, "dictionary": "Commands: play — Play the current track; pause — Pause playback; ...\nClasses: track [props: name, artist, album, duration, ...]" } ``` The `dictionary` field contains a condensed summary of the app's AppleScript scripting dictionary (sdef), automatically fetched in parallel with script execution and cached. It lists available commands and classes with their properties. This is especially useful on errors — the dictionary shows what commands are actually available so the script can be corrected. **Examples:** ```javascript // Play Spotify await context.machine.execute('apps.automate', { app: 'Spotify', script: 'play' }); // Get current track await context.machine.execute('apps.automate', { app: 'Spotify', script: 'get name of current track' }); // Set volume await context.machine.execute('apps.automate', { app: 'Spotify', script: 'set sound volume to 50' }); // Create a reminder await context.machine.execute('apps.automate', { app: 'Reminders', script: 'make new reminder with properties {name:"Buy groceries", body:"Milk, eggs, bread"}' }); // Open a URL in Safari await context.machine.execute('apps.automate', { app: 'Safari', script: 'open location "https://example.com"' }); ``` ### browser.data Read browser tabs, history, and bookmarks from Safari and Chrome. **Risk:** High **Platforms:** macOS **Parameters:** | Name | Type | Description | |---|---|---| | `action` | string | Required. One of: `tabs`, `history`, `bookmarks` | | `browser` | string | `"safari"` or `"chrome"` (default: `"safari"`) | | `limit` | number | Max results (default: 50, max: 500) | **Returns (tabs example):** ```json { "action": "tabs", "browser": "safari", "tabs": [ { "title": "GitHub", "url": "https://github.com", "windowIndex": 0 }, { "title": "Google", "url": "https://google.com", "windowIndex": 0 } ], "count": 2 } ``` **Returns (history example):** ```json { "action": "history", "browser": "safari", "history": [ { "url": "https://github.com", "title": "GitHub", "visit_date": "2025-03-14 10:30:00" } ], "count": 1, "limit": 50 } ``` ### contacts.read Read contacts from macOS Contacts. **Risk:** High **Platforms:** macOS **Parameters:** | Name | Type | Description | |---|---|---| | `search` | string | Filter by name (partial match) | | `limit` | number | Max contacts to return (default: 50, max: 500) | | `fields` | string\[] | Fields to include: `name`, `email`, `phone`, `company`, `address`, `birthday`, `notes` (default: `["name", "email", "phone"]`) | **Returns:** ```json { "contacts": [ { "firstName": "Jane", "lastName": "Doe", "name": "Jane Doe", "emails": [{ "label": "work", "value": "jane@example.com" }], "phones": [{ "label": "mobile", "value": "+15551234567" }] } ], "count": 1, "total": 342, "search": null, "fields": ["name", "email", "phone"] } ``` ### calendar.read Read events from macOS Calendar. **Risk:** High **Platforms:** macOS **Parameters:** | Name | Type | Description | |---|---|---| | `from` | string | Start date (ISO format, default: today) | | `to` | string | End date (ISO format, default: 7 days from start) | | `calendar` | string | Filter by calendar name | | `limit` | number | Max events to return (default: 50, max: 500) | **Returns:** ```json { "events": [ { "summary": "Team Standup", "calendar": "Work", "startDate": "2025-03-14T09:00:00.000Z", "endDate": "2025-03-14T09:30:00.000Z", "location": "Zoom", "description": null, "allDay": false } ], "count": 1, "calendarCount": 4, "from": "2025-03-14T00:00:00.000Z", "to": "2025-03-21T00:00:00.000Z" } ``` ### system.control Read and control system settings. **Risk:** Medium **Platforms:** macOS **Parameters:** | Name | Type | Description | |---|---|---| | `action` | string | Required. One of: `volume`, `set_volume`, `dark_mode`, `set_dark_mode`, `wifi_status`, `bluetooth_devices`, `battery`, `running_apps`, `disk_usage`, `network_info` | | `level` | number | Volume level (0-100) for `set_volume` | | `enabled` | boolean | Dark mode state for `set_dark_mode` | **Returns (volume example):** ```json { "action": "volume", "outputVolume": 75, "inputVolume": 80, "outputMuted": false } ``` **Returns (battery example):** ```json { "action": "battery", "percent": 87, "powerSource": "AC Power", "status": "charging" } ``` **Returns (running\_apps example):** ```json { "action": "running_apps", "apps": [ { "name": "Spotify", "bundleId": "com.spotify.client", "pid": 1234 }, { "name": "Safari", "bundleId": "com.apple.Safari", "pid": 5678 } ], "count": 15 } ``` **Returns (network\_info example):** ```json { "action": "network_info", "interfaces": [ { "name": "en0", "ipv4": "192.168.1.100" } ], "publicIp": "203.0.113.1" } ``` ### file.transfer Upload files from the Mac to PIE cloud storage, or download files from the cloud to the Mac. Supports files up to **100MB**. Uploaded files appear in the user's PIE Files and are associated with the calling plugin. **Risk:** High **Platforms:** macOS, Linux, Windows **Parameters:** | Name | Type | Description | |---|---|---| | `action` | string | Required. `"upload"` or `"download"` | **Action: upload** — Upload a file from Mac to cloud | Name | Type | Description | |---|---|---| | `path` | string | File path on the Mac (supports `~` for home dir) | **Returns (upload):** ```json { "action": "upload", "fileId": "be3df226-9f8a-4ebe-9400-7cc238023bf3", "url": "/api/files/be3df226-.../view", "size": 76292, "filename": "report.pdf", "mimeType": "application/pdf", "sourcePath": "/Users/jake/Documents/report.pdf" } ``` **Action: download** — Download a file from cloud to Mac | Name | Type | Description | |---|---|---| | `url` | string | URL to download from | | `gcsPath` | string | GCS path from a previous upload (alternative to `url`) | | `savePath` | string | Path on Mac to save the file (required) | **Returns (download):** ```json { "action": "download", "savedTo": "/Users/jake/Downloads/report.pdf", "size": 76292, "filename": "report.pdf" } ``` **Convenience methods:** Use `context.machine.upload(path)` and `context.machine.download(url, savePath)` instead of calling `execute` directly. *** ## Error Codes | Error | Description | |---|---| | `MACHINE_OFFLINE` | No machine is currently connected | | `MACHINE_TIMEOUT` | Machine didn't respond within 60 seconds | | `Capability not found or is disabled` | The requested capability is not available on the machine | | `Plugin does not declare machine capability` | The plugin's manifest doesn't include this capability | | `User has not approved machine capability` | The user hasn't approved this capability for the plugin | --- --- url: /docs/getting-started/machine-connect.md --- # Machine Connect PIE Connect lets you link your Mac to your PIE account, giving AI agents secure access to machine capabilities like reading iMessages, clipboard contents, and more. ## What is PIE Connect? PIE Connect is a lightweight CLI that runs on your Mac and maintains a persistent connection to the PIE server. When plugins request machine capabilities (like reading your clipboard or sending notifications), the command is routed through the server to your Mac, executed locally, and the result is returned to the plugin. ## Installation ```bash npm install -g @pie/connect ``` ## Linking Your Machine 1. Go to **Settings > Machines** in the PIE web app 2. Click **"Generate Link Code"** — you'll get a short-lived code 3. On your Mac terminal, run: ```bash pie login ``` That's it! The CLI will: * Register your machine with PIE * Install a background service that starts automatically on login * Connect to the PIE server immediately ## Checking Status ```bash pie status ``` This shows whether the service is running, your machine name, and which capabilities are enabled. ## Managing the Service ```bash # Start the background service (if stopped) pie start # Stop the service (machine goes offline) pie stop # Restart the service pie restart # View recent logs pie logs pie logs -n 100 ``` ## Configuration ```bash # Show current config pie config show # Point to a different PIE server (for dev/staging) pie config set-server http://localhost:3000 # Disable a capability pie config disable-capability shell.run # Re-enable a capability pie config enable-capability shell.run ``` ## Disconnecting To completely remove the machine from your PIE account: ```bash pie unlink ``` This will: * Stop and remove the background service * Mark the machine as offline on the server * Delete all local configuration You can also disconnect from the web UI at **Settings > Machines** by clicking the **Disconnect** button next to any machine. ## Troubleshooting ### Messages capability requires Full Disk Access If a plugin tries to read your iMessages and gets a permission error, you need to grant **Full Disk Access** to your terminal app: 1. Open **System Settings > Privacy & Security > Full Disk Access** 2. Add your terminal app (Terminal.app, iTerm2, etc.) 3. Restart the PIE Connect service: `pie restart` ### Machine shows as offline * Check that the service is running: `pie status` * Check logs for errors: `pie logs` * Try restarting: `pie restart` * Make sure your Mac has internet access ### Connection keeps dropping Check the logs (`pie logs`) for WebSocket errors. The CLI automatically reconnects with exponential backoff, so brief network interruptions should resolve automatically. --- --- url: /docs/guides/making-api-calls.md --- # Making API Calls Learn how to make HTTP requests from your agents using `context.fetch()`. ## Basic Usage ```js const response = await context.fetch(url, options); ``` ## GET Request ```js async function handler(input, context) { const response = await context.fetch( 'https://api.example.com/data' ); if (!response.ok) { return { error: true, message: `Request failed: ${response.status}` }; } const data = JSON.parse(response.body); return data; } ``` ## POST Request with JSON ```js const response = await context.fetch('https://api.example.com/items', { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ name: 'New Item', value: 42, }), }); ``` ## Adding Headers ```js const response = await context.fetch('https://api.example.com/protected', { headers: { 'Authorization': `Bearer ${context.secrets.API_TOKEN}`, 'X-Custom-Header': 'value', }, }); ``` ## Response Object ```js { ok: boolean, // true if status 200-299 status: number, // HTTP status code (200, 404, 500, etc.) body: string, // Response body as string } ``` ## Error Handling Always handle potential failures: ```js async function handler(input, context) { try { const response = await context.fetch('https://api.example.com/data'); // Check HTTP status if (!response.ok) { // Try to parse error message try { const error = JSON.parse(response.body); return { error: true, message: error.message || `HTTP ${response.status}` }; } catch { return { error: true, message: `HTTP ${response.status}` }; } } // Parse successful response const data = JSON.parse(response.body); return data; } catch (error) { // Network error, timeout, etc. return { error: true, message: error.message || 'Request failed' }; } } ``` ## URL Encoding Always encode user input in URLs: ```js const query = encodeURIComponent(input.searchTerm); const response = await context.fetch( `https://api.example.com/search?q=${query}` ); ``` ## Form Data For `application/x-www-form-urlencoded`: ```js const params = new URLSearchParams({ username: 'user', password: 'pass', }); const response = await context.fetch('https://api.example.com/login', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', }, body: params.toString(), }); ``` ## Limits | Limit | Value | |-------|-------| | Requests per execution | 10 | | Request timeout | 30 seconds | | Response body size | 10 MB | ## Multiple API Calls Make multiple requests (up to the limit): ```js async function handler(input, context) { const { ids } = input; const results = []; for (const id of ids.slice(0, 10)) { const response = await context.fetch( `https://api.example.com/items/${id}` ); if (response.ok) { results.push(JSON.parse(response.body)); } } return { items: results, count: results.length }; } ``` ## Common Patterns ### REST API ```js // GET list await context.fetch('https://api.example.com/items'); // GET single await context.fetch(`https://api.example.com/items/${id}`); // POST create await context.fetch('https://api.example.com/items', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(newItem), }); // PUT update await context.fetch(`https://api.example.com/items/${id}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(updatedItem), }); // DELETE await context.fetch(`https://api.example.com/items/${id}`, { method: 'DELETE', }); ``` ### GraphQL ```js const response = await context.fetch('https://api.example.com/graphql', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${context.secrets.API_TOKEN}`, }, body: JSON.stringify({ query: ` query GetUser($id: ID!) { user(id: $id) { name email } } `, variables: { id: input.userId }, }), }); const { data, errors } = JSON.parse(response.body); ``` ## Debugging Log responses during development: ```js const response = await context.fetch(url); console.log('Status:', response.status); console.log('Body:', response.body.substring(0, 500)); ``` Note: `console.log` is available in the sandbox but output may be limited. --- --- url: /docs/reference/manifest.md --- # Manifest Schema The manifest defines your agent's behavior, triggers, and capabilities. ::: tip Security Scanning All plugins are automatically security scanned when created or updated. The scan checks your code and manifest against [10 security criteria](/guides/security-scanning). Network requests are verified against your plugin's stated purpose, so make sure your manifest accurately describes what your plugin does. ::: ## Full Schema ```json { "trigger": "always" | "auto" | "manual", "oauth": { "provider": "google" | "github" | "slack" | "notion" | "custom", "providerName": "string (for custom providers)", "authorizationUrl": "string (for custom providers)", "tokenUrl": "string (for custom providers)", "userInfoUrl": "string (optional)", "revokeUrl": "string (optional)", "scopes": ["string"], "clientIdSecret": "string (key in developerSecrets)", "clientSecretSecret": "string (key in developerSecrets)" }, "developerSecrets": { "SECRET_NAME": { "description": "string", "required": boolean } }, "userSecrets": { "SECRET_NAME": { "description": "string", "required": boolean } }, "database": { "tables": { "table_name": { "columns": { "column_name": "column_definition (type + constraints + default)" }, "rls": "column_name (optional, enables Row-Level Security)" } } }, "runtime": { "persistent": boolean, "timeoutMs": number, "maxSessionAge": number }, "tool": { "name": "string", "description": "string", "parameters": { "type": "object", "properties": { "param_name": { "type": "string" | "number" | "boolean" | "array" | "object", "description": "string", "enum": ["value1", "value2"] // optional } }, "required": ["param_name"] } } } ``` ## Public App Packaging in `.pie` Files Public apps are authored in fenced sections of the `.pie` file. They are **not** declared inside the JSON `manifest` object itself. Use these sections: ```text ===public-config=== entry: index.html routes: - path: / spa: true ===public-config=== ===public-file:index.html=== ... ===public-file:index.html=== ===public-file:app.js=== console.log('hello'); ===public-file:app.js=== ``` ### Public App Fields | Section | Purpose | |---------|---------| | `===public-config===` | Declares the entry file and optional SPA route rules | | `===public-file:path===` | Adds a static file to the public bundle | ### `public-config` | Field | Type | Description | |-------|------|-------------| | `entry` | string | Entry file served for the root URL, usually `index.html` | | `routes` | array | Optional route rules for SPA fallback | | `routes[].path` | string | Route prefix | | `routes[].spa` | boolean | If `true`, unmatched paths under the route serve the entry file | ### Authoring Limits | Limit | Value | |-------|-------| | Max public files | 50 | | Max file size | 512 KB per file | | Max total bundle size | 2 MB | Paths must be relative, must not start with `/`, and must not contain `..`. See the [Public Apps and Routes guide](/guides/public-apps) for full examples, hosted URL structure, public actions, uploads, and custom domains. ## Trigger Types | Trigger | Agent Types | Description | |---------|--------------|-------------| | `"always"` | Skills (always-on) | Active in every conversation | | `"auto"` | Tools, Connectors, Skills (on-demand) | AI decides when to call | | `"manual"` | Any | User must explicitly invoke | ### Always-On Skills (trigger: "always") ```json { "trigger": "always" } ``` Always-on skills have no tool definition — their prompt is injected into every conversation. ### On-Demand Skills / Prompt Tools (trigger: "auto" + tool) ```json { "trigger": "auto", "tool": { "name": "analyze_nda", "description": "Analyze an NDA for risks and unusual clauses", "parameters": { "type": "object", "properties": { "document_text": { "type": "string", "description": "The full NDA text to analyze" } }, "required": ["document_text"] } } } ``` On-demand skills (prompt tools) are `tier: skill` plugins that include a `tool:` definition. They have no executable code — when the AI calls the tool, the skill's prompt template is returned as the tool result. This is ideal for domain expertise and large knowledge bases that shouldn't be in every conversation. ### Tools (trigger: "auto") ```json { "trigger": "auto", "tool": { "name": "get_weather", "description": "Get current weather for a city", "parameters": { ... } } } ``` ### Connectors (trigger: "auto" + oauth) ```json { "trigger": "auto", "oauth": { ... }, "tool": { ... } } ``` ## OAuth Configuration ### Built-in Providers PIE has built-in support for: | Provider | OAuth URLs | Notes | |----------|------------|-------| | `google` | Automatic | Gmail, Drive, Calendar, etc. | | `github` | Automatic | Repositories, Issues, etc. | | `slack` | Automatic | Workspaces, Channels, etc. | | `notion` | Automatic | Pages, Databases, etc. | For built-in providers, only specify `scopes`: ```json { "oauth": { "provider": "google", "scopes": [ "https://www.googleapis.com/auth/gmail.readonly", "https://www.googleapis.com/auth/gmail.send" ], "clientIdSecret": "GOOGLE_CLIENT_ID", "clientSecretSecret": "GOOGLE_CLIENT_SECRET" } } ``` ### Custom Providers For any other OAuth 2.0 service: ```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" } } ``` ## Developer Secrets Secrets are encrypted and stored securely. Define them in the manifest: ```json { "developerSecrets": { "API_KEY": { "description": "Your service API key", "required": true }, "OPTIONAL_SECRET": { "description": "Optional configuration", "required": false } } } ``` Access in code via `context.secrets.API_KEY`. ## User-Configurable Fields Allow users to customize agent behavior through a settings UI. Define fields in your manifest: ```json { "userFields": { "topics": { "type": "tags", "label": "News Topics", "description": "Topics you want to track", "default": ["technology", "business"], "required": true }, "maxResults": { "type": "number", "label": "Max Results", "min": 1, "max": 10, "default": 5 }, "emailDigest": { "type": "boolean", "label": "Send Email Digest", "default": false }, "frequency": { "type": "select", "label": "Frequency", "options": [ { "value": "daily", "label": "Daily" }, { "value": "weekly", "label": "Weekly" } ], "default": "daily" } } } ``` Access in code via `context.userConfig`: ```js async function handler(input, context) { const { topics, maxResults, frequency } = context.userConfig; // Use the user's configured values } ``` ### Field Types | Type | Description | Extra Options | |------|-------------|---------------| | `text` | Single-line text input | `placeholder` | | `number` | Numeric input | `min`, `max` | | `boolean` | Toggle switch | - | | `select` | Dropdown menu | `options: [{ value, label }]` | | `tags` | Array of strings | `placeholder` | ### Field Options | Option | Type | Description | |--------|------|-------------| | `type` | string | **Required.** One of: `text`, `number`, `boolean`, `select`, `tags` | | `label` | string | **Required.** Display label for the field | | `description` | string | Help text shown below the field | | `default` | any | Default value (type must match field type) | | `required` | boolean | Whether the field is required | | `placeholder` | string | Placeholder text (text, tags) | | `min` | number | Minimum value (number) | | `max` | number | Maximum value (number) | | `options` | array | Options for select: `[{ value: string, label: string }]` | ### Example: News Digest Agent ```json { "automation": { "triggers": [{ "type": "cron", "default": "0 8 * * *" }] }, "userFields": { "topics": { "type": "tags", "label": "News Topics", "description": "Topics you want to track (e.g., AI, climate, startups)", "default": ["technology", "business"], "required": true }, "maxArticles": { "type": "number", "label": "Articles per Topic", "min": 1, "max": 10, "default": 3 }, "timeRange": { "type": "select", "label": "Time Range", "options": [ { "value": "day", "label": "Last 24 Hours" }, { "value": "week", "label": "Past Week" } ], "default": "day" } }, "developerSecrets": { "TAVILY_API_KEY": { "description": "Tavily API key", "required": true } } } ``` ## `heartbeatEvents` Object Use `heartbeatEvents` to publish a curated catalog of plugin activity that users can subscribe to from **Heartbeats**. This is different from `automation.triggers[].events`: * `automation.triggers[].events` declares raw webhook event types for automation plugins. * `heartbeatEvents.events[]` declares developer-approved, user-facing events backed by exact tool, widget, public-action, or webhook matchers. PIE can suggest heartbeat events automatically when you save a plugin, but the manifest catalog is the source of truth that users actually see. ```json { "heartbeatEvents": { "events": [ { "id": "submission_received", "displayName": "New form submission", "description": "Fires when a visitor submits the public form.", "enabled": true, "matchers": [ { "source": "public", "actionId": "submit_form" } ] }, { "id": "form_created", "displayName": "Form created", "description": "Fires when a new form is created from chat.", "enabled": true, "matchers": [ { "source": "tool", "action": "create_form" } ] } ] } } ``` ### `heartbeatEvents.events[]` fields | Field | Type | Description | |-------|------|-------------| | `id` | string | Stable event identifier stored on the user's heartbeat schedule. Use lowercase snake\_case or kebab-case. | | `displayName` | string | Friendly label shown in the Heartbeats UI. | | `description` | string | Optional help text shown to developers and users. | | `enabled` | boolean | Optional. Set to `false` to keep the event in the manifest but hide it from users. | | `matchers` | array | One or more exact runtime matchers that should fire this curated event. | ### Matcher Shapes `heartbeatEvents` supports these exact matcher objects: * `{ "source": "tool", "action": "create_form" }` * `{ "source": "widget", "actionId": "publish_form" }` * `{ "source": "public", "actionId": "submit_form" }` * `{ "source": "webhook", "eventName": "invoice.paid" }` Use multiple matchers when different surfaces represent the same business outcome. For example, a "Blog post published" event might match both a chat tool action and a widget action. ## Runtime Configuration Control how your agent's sandbox behaves at runtime. Add a `runtime` block to your manifest: ```json { "runtime": { "persistent": true, "timeoutMs": 600000, "maxSessionAge": 86400000 } } ``` ### Properties | Property | Type | Default | Description | |----------|------|---------|-------------| | `timeoutMs` | number | 120000 | Maximum execution time per invocation in milliseconds. Min: 30000, Max: 600000. | | `persistent` | boolean | false | Enable pause/resume sandbox sessions. When true, the sandbox is paused after each invocation instead of killed, preserving filesystem and process state across messages. | | `maxSessionAge` | number | 86400000 | Maximum total lifetime of a persistent session in milliseconds before auto-cleanup. Only applies when `persistent: true`. | ### Persistent Sandboxes When `persistent` is `true`, PIE manages the sandbox lifecycle differently: * **First invocation**: A new sandbox is created from the assigned template. * **After execution**: The sandbox is **paused** (not killed). Billing stops, but filesystem and memory state are preserved. * **Subsequent invocations**: The paused sandbox is **resumed** instantly, picking up exactly where it left off. * **Session end**: The plugin can request a kill by setting `requestKill: true` in session metadata, or sessions are automatically cleaned up after `maxSessionAge`. This is ideal for agents that need to maintain state across multiple messages — for example, a coding agent that keeps a cloned repository, or a database agent that maintains a connection pool. See [Persistent Sandboxes Guide](/guides/persistent-sandboxes) for a full walkthrough. ### Example: Long-running tool ```json { "trigger": "auto", "runtime": { "timeoutMs": 300000 }, "tool": { "name": "video_processor", "description": "Process and transcode video files", "parameters": { ... } } } ``` ### Example: Persistent coding agent ```json { "trigger": "auto", "runtime": { "persistent": true, "timeoutMs": 600000, "maxSessionAge": 86400000 }, "userSecrets": { "GITHUB_TOKEN": { "description": "GitHub PAT with repo scope", "required": true } }, "tool": { "name": "code_agent", "description": "AI coding agent with persistent sandbox", "parameters": { ... } } } ``` ## User Secrets Secrets that the **end user** provides when they install your agent. Unlike developer secrets (which you set once), user secrets are configured per-user through the agent settings UI. ```json { "userSecrets": { "API_KEY": { "description": "Your personal API key from the service", "required": true }, "OPTIONAL_TOKEN": { "description": "Optional access token for premium features", "required": false } } } ``` Access in code via `context.secrets.API_KEY` (user secrets are merged with developer secrets). ## Database Schema Define database tables declaratively. When you save a plugin with a `database` section, PIE auto-provisions the database (if needed) and syncs the schema. The sync is **additive only** — it creates tables and adds columns, but never drops or alters existing objects. ```json { "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" } } } } ``` ### Table Definition | Field | Type | Description | |-------|------|-------------| | `columns` | `Record` | **Required.** Map of column name to column definition | | `rls` | `string` | Optional. Column name for Row-Level Security. When set, PIE auto-creates an RLS policy filtering by `app.user_id` | ### Column Definitions Column values are raw Postgres column definitions. You can use any valid Postgres syntax: | Example | Description | |---------|-------------| | `"text"` | Simple text column | | `"text not null"` | Required text column | | `"uuid primary key default gen_random_uuid()"` | UUID primary key with auto-generation | | `"integer not null default 0"` | Integer with default value | | `"timestamptz default now()"` | Timestamp defaulting to current time | | `"jsonb"` | JSON column | | `"boolean not null default false"` | Boolean with default | | `"text not null default current_setting('app.user_id', true)"` | Auto-populated with end-user ID | ### RLS Behavior When `rls` is set on a table, PIE automatically: 1. Enables Row-Level Security on the table 2. Forces RLS for the table owner 3. Creates a policy: rows are filtered where ` = current_setting('app.user_id', true)` This means queries through `context.managedDb.query()` automatically return only the current user's data. ### Sync Behavior | Scenario | Action | |----------|--------| | Table in manifest, not in DB | `CREATE TABLE` | | Column in manifest, not on existing table | `ALTER TABLE ADD COLUMN` | | Table/column in DB, not in manifest | Ignored (no drops) | | Column exists with different type | Warning (no alter) | ### Examples **Simple table:** ```json { "database": { "tables": { "items": { "columns": { "id": "uuid primary key default gen_random_uuid()", "name": "text not null", "data": "jsonb", "created_at": "timestamptz default now()" } } } } } ``` **Table with RLS:** ```json { "database": { "tables": { "user_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" } } } } ``` **Multi-table manifest:** ```json { "database": { "tables": { "projects": { "columns": { "id": "uuid primary key default gen_random_uuid()", "pie_user_id": "text not null default current_setting('app.user_id', true)", "name": "text not null", "description": "text", "created_at": "timestamptz default now()" }, "rls": "pie_user_id" }, "tasks": { "columns": { "id": "uuid primary key default gen_random_uuid()", "pie_user_id": "text not null default current_setting('app.user_id', true)", "project_id": "uuid not null", "title": "text not null", "status": "text not null default 'pending'", "created_at": "timestamptz default now()" }, "rls": "pie_user_id" } } } } ``` ::: tip Foreign Keys Foreign keys are not auto-managed from the manifest in v1 (due to table ordering complexity), but you can create them manually via a direct connection or `context.managedDb.query()`. ::: ## Tool Parameters The `parameters` object uses [JSON Schema](https://json-schema.org/) format: ```json { "tool": { "name": "search_emails", "description": "Search emails by query", "parameters": { "type": "object", "properties": { "query": { "type": "string", "description": "Search query (e.g., 'from:john subject:meeting')" }, "maxResults": { "type": "integer", "description": "Maximum results to return (default: 10)" }, "folder": { "type": "string", "enum": ["inbox", "sent", "drafts", "trash"], "description": "Which folder to search" } }, "required": ["query"] } } } ``` ### Supported Types | Type | JSON Schema | Example | |------|-------------|---------| | String | `"string"` | `"hello"` | | Number | `"number"` or `"integer"` | `42`, `3.14` | | Boolean | `"boolean"` | `true`, `false` | | Array | `"array"` | `[1, 2, 3]` | | Object | `"object"` | `{ "key": "value" }` | ### Enums Restrict values to a predefined set: ```json { "action": { "type": "string", "enum": ["search", "read", "send", "delete"], "description": "The action to perform" } } ``` ## Automation Configuration Automations run on schedules or in response to webhooks, not AI triggers. ### automation Object ```json { "automation": { "triggers": [ { "type": "cron" | "webhook" | "interval", "default": "string (cron expression)", "everyMs": "number (for interval type)", "description": "string", "eventTypeField": "string (webhook only, dot-path to event type in payload)", "events": [ { "name": "string", "description": "string" } ] } ], "timeout": 120, "onWebhook": true, "allowManualRun": true } } ``` ### allowManualRun Flag Set `allowManualRun: true` to show a "Run Now" button in the agent settings. This allows users to manually trigger the automation on demand: ```json { "automation": { "triggers": [{ "type": "cron", "default": "0 8 * * *" }], "allowManualRun": true } } ``` When triggered manually, the handler receives `input.triggeredBy === 'manual'`. ### onWebhook Flag Set `onWebhook: true` to indicate your agent exports an `onWebhook` handler for processing incoming webhook payloads: ```json { "automation": { "triggers": [{ "type": "webhook" }], "onWebhook": true } } ``` Your agent can then export: ```js async function onWebhook(input, context) { const payload = input.triggerData; // Process webhook payload } module.exports = { handler, onWebhook }; ``` ### Declaring Webhook Events If your agent receives webhooks from an external service, you can declare the specific event types your webhook produces. This enables users to create heartbeats that trigger only on specific events (e.g., "only when a PR is merged" or "only when an invoice fails"). ```json { "automation": { "triggers": [ { "type": "webhook", "eventTypeField": "type", "events": [ { "name": "invoice.paid", "description": "Invoice successfully paid" }, { "name": "charge.failed", "description": "A charge attempt failed" } ] } ], "onWebhook": true } } ``` | Field | Type | Description | |-------|------|-------------| | `eventTypeField` | string | Dot-path to the field in the webhook payload that identifies the event type. For example, `"type"` for Stripe, `"event.type"` for Slack, or `"_headers.x-github-event"` for services that send the event type in an HTTP header. | | `events` | array | List of event types this webhook can produce. Each entry has a `name` (matched against the payload value) and an optional `description` (shown in the UI). | When users create a heartbeat with a "Plugin Event" trigger, the events you declare here populate the dropdown they see, so include clear descriptions. **Header-based event types:** Some services (like GitHub) put the event type in an HTTP header rather than the payload body. The webhook system automatically injects selected request headers into `input.triggerData._headers`, so you can reference them with a dot-path like `_headers.x-github-event`: ```json { "automation": { "triggers": [ { "type": "webhook", "eventTypeField": "_headers.x-github-event", "events": [ { "name": "push", "description": "Push to a branch" }, { "name": "pull_request", "description": "Pull request opened, closed, or merged" }, { "name": "issues", "description": "Issue opened, closed, or edited" } ] } ], "onWebhook": true } } ``` ### Trigger Types | Type | Description | Configuration | |------|-------------|---------------| | `cron` | Schedule via cron expression | `default: "0 7 * * *"` | | `webhook` | External HTTP trigger | Auto-generates URL. Optionally add `eventTypeField` + `events[]` to declare event types. | | `interval` | Fixed interval | `everyMs: 3600000` | ### Cron Expressions Common patterns: | Expression | Meaning | |------------|---------| | `0 7 * * *` | Daily at 7am | | `*/15 * * * *` | Every 15 minutes | | `0 9 * * 1-5` | Weekdays at 9am | | `0 0 1 * *` | First day of month | **Note:** Cron times use the user's configured timezone. ### Timeout Maximum execution time in seconds. Default: 60. Maximum: 300. ```json { "automation": { "timeout": 120 } } ``` ### Sandbox Template If your agent needs custom npm packages or system dependencies, assign a **sandbox template** via the Developer Portal UI (Settings tab > Sandbox Template dropdown). Templates are not specified in the manifest — they are managed separately in the Templates tab. See [Context API - Sandbox Templates](/reference/context-api#sandbox-templates) for details. ## Complete Examples ### Always-On Skill Manifest ```json { "trigger": "always" } ``` ### On-Demand Skill (Prompt Tool) 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" }, "focus_areas": { "type": "string", "description": "Optional: specific concerns to focus on" } }, "required": ["document_text"] } } } ``` ### Automation Manifest ```json { "automation": { "triggers": [ { "type": "cron", "default": "0 7 * * *", "description": "Daily at 7am" } ], "timeout": 60 }, "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 } } } ``` ### Webhook Automation Manifest ```json { "automation": { "triggers": [ { "type": "webhook" } ], "timeout": 30 } } ``` ### Combined Tool + Automation ```json { "trigger": "auto", "tool": { "name": "get_weather", "description": "Get current weather" }, "automation": { "triggers": [ { "type": "cron", "default": "0 7 * * *" } ] } } ``` ### Tool Manifest ```json { "trigger": "auto", "developerSecrets": { "WEATHER_API_KEY": { "description": "API key from weatherapi.com", "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"] } } } ``` ### Connector Manifest ```json { "trigger": "auto", "oauth": { "provider": "google", "scopes": [ "https://www.googleapis.com/auth/gmail.readonly", "https://www.googleapis.com/auth/gmail.send" ], "clientIdSecret": "GOOGLE_CLIENT_ID", "clientSecretSecret": "GOOGLE_CLIENT_SECRET" }, "developerSecrets": { "GOOGLE_CLIENT_ID": { "description": "Google OAuth Client ID", "required": true }, "GOOGLE_CLIENT_SECRET": { "description": "Google OAuth Client Secret", "required": true } }, "tool": { "name": "gmail", "description": "Access Gmail", "parameters": { "type": "object", "properties": { "action": { "type": "string", "enum": ["search", "read", "send"], "description": "Gmail action" } }, "required": ["action"] } } } ``` ## Marketplace Discovery When your plugin is published with `public` or `official` visibility, its description and tags are indexed into a vector store used for marketplace tool suggestions. During chat, PIE searches this store to recommend relevant plugins to users who don't have them installed. To maximize your plugin's discoverability: * **`description`**: Write a clear, specific description of what your plugin does. Include key use cases and capabilities. Avoid vague descriptions like "A useful tool" — instead write "Create, read, and manipulate Excel spreadsheets (.xlsx) with formulas, charts, and pivot tables." * **`tags`**: Add relevant keywords as tags. These are indexed alongside the description. Use specific terms: `["spreadsheet", "excel", "xlsx", "data-analysis", "formulas"]` rather than generic ones like `["tool", "office"]`. The combination of a strong description and precise tags ensures your plugin surfaces when users need it, even if they don't know it exists. ## Machine Capabilities Plugins that need to interact with the user's local machine can declare `machineCapabilities` in the manifest. When a user installs such a plugin, they must approve the requested capabilities. ```json { "machineCapabilities": [ "machine.info", "clipboard.read", "notifications.send", "messages.read" ] } ``` **Available capabilities:** | Capability | Risk | Description | |---|---|---| | `machine.info` | Low | Read hostname, OS, uptime | | `clipboard.read` | Medium | Read clipboard contents | | `notifications.send` | Low | Send desktop notifications | | `messages.read` | High | Read iMessages (macOS) | | `shell.run` | Critical | Execute shell commands | See the [Machine Capabilities Guide](/guides/machine-capabilities) for details on using the `context.machine` API in your plugin code. --- --- url: /docs/examples/notion-connector.md --- # Notion Connector Example An OAuth connector for Notion demonstrating custom provider configuration. ## Overview * **Type:** Connector * **OAuth Provider:** Custom (Notion) * **Capabilities:** Search pages, Read pages, List databases ## Key Difference: Custom Provider Unlike Google (built-in), Notion requires custom OAuth URLs: ```json { "oauth": { "provider": "custom", "providerName": "Notion", "authorizationUrl": "https://api.notion.com/v1/oauth/authorize", "tokenUrl": "https://api.notion.com/v1/oauth/token", "scopes": [], "clientIdSecret": "NOTION_CLIENT_ID", "clientSecretSecret": "NOTION_CLIENT_SECRET" } } ``` ## Full Code ```js /** * Notion Connector - Search and read Notion pages * * Demonstrates: * - Custom OAuth provider configuration * - Notion API v2022-06-28 * - Handling Notion's unique data structures */ const NOTION_API = 'https://api.notion.com/v1'; const NOTION_VERSION = '2022-06-28'; async function handler(input, context) { const { action, ...params } = input; // Check OAuth connection if (!await context.oauth.isConnected()) { return { error: true, message: 'Notion not connected. Please connect your Notion account.', requiresAuth: true }; } try { switch (action) { case 'search': return await search(params, context); case 'getPage': return await getPage(params, context); case 'listDatabases': return await listDatabases(context); case 'queryDatabase': return await queryDatabase(params, context); default: return { error: true, message: `Unknown action: ${action}. Valid: search, getPage, listDatabases, queryDatabase` }; } } catch (error) { return { error: true, message: error.message || 'Notion operation failed' }; } } // Helper: Make Notion API request with required headers async function notionFetch(context, endpoint, options = {}) { const url = endpoint.startsWith('http') ? endpoint : `${NOTION_API}${endpoint}`; return context.oauth.fetch(url, { ...options, headers: { 'Content-Type': 'application/json', 'Notion-Version': NOTION_VERSION, ...options.headers, }, }); } // Helper: Extract title from Notion page/database function extractTitle(obj) { if (obj.properties?.title?.title?.[0]?.plain_text) { return obj.properties.title.title[0].plain_text; } if (obj.properties?.Name?.title?.[0]?.plain_text) { return obj.properties.Name.title[0].plain_text; } if (obj.title?.[0]?.plain_text) { return obj.title[0].plain_text; } return 'Untitled'; } async function search({ query, filter }, context) { if (!query) { return { error: true, message: 'Search query is required' }; } const body = { query, page_size: 20, }; // Optional: filter by type if (filter === 'page' || filter === 'database') { body.filter = { property: 'object', value: filter }; } const resp = await notionFetch(context, '/search', { method: 'POST', body: JSON.stringify(body), }); if (!resp.ok) { throw new Error(`Notion search failed: ${resp.status}`); } const data = JSON.parse(resp.body); const results = data.results.map(item => ({ id: item.id, type: item.object, title: extractTitle(item), url: item.url, lastEdited: item.last_edited_time, })); return { success: true, action: 'search', query, count: results.length, results, }; } async function getPage({ pageId }, context) { if (!pageId) { return { error: true, message: 'Page ID is required' }; } // Get page metadata const pageResp = await notionFetch(context, `/pages/${pageId}`); if (!pageResp.ok) { if (pageResp.status === 404) { return { error: true, message: 'Page not found or not shared with integration' }; } throw new Error(`Failed to get page: ${pageResp.status}`); } const page = JSON.parse(pageResp.body); // Get page content (blocks) const blocksResp = await notionFetch(context, `/blocks/${pageId}/children`); let content = []; if (blocksResp.ok) { const blocksData = JSON.parse(blocksResp.body); content = blocksData.results.map(block => { const type = block.type; const data = block[type]; // Extract text from rich_text arrays if (data?.rich_text) { return { type, text: data.rich_text.map(t => t.plain_text).join(''), }; } return { type, data }; }); } return { success: true, action: 'getPage', page: { id: page.id, title: extractTitle(page), url: page.url, created: page.created_time, lastEdited: page.last_edited_time, properties: page.properties, }, content, }; } async function listDatabases(context) { const resp = await notionFetch(context, '/search', { method: 'POST', body: JSON.stringify({ filter: { property: 'object', value: 'database' }, page_size: 50, }), }); if (!resp.ok) { throw new Error(`Failed to list databases: ${resp.status}`); } const data = JSON.parse(resp.body); const databases = data.results.map(db => ({ id: db.id, title: extractTitle(db), url: db.url, description: db.description?.[0]?.plain_text, })); return { success: true, action: 'listDatabases', count: databases.length, databases, }; } async function queryDatabase({ databaseId, filter, sorts }, context) { if (!databaseId) { return { error: true, message: 'Database ID is required' }; } const body = { page_size: 50 }; if (filter) { body.filter = typeof filter === 'string' ? JSON.parse(filter) : filter; } if (sorts) { body.sorts = typeof sorts === 'string' ? JSON.parse(sorts) : sorts; } const resp = await notionFetch(context, `/databases/${databaseId}/query`, { method: 'POST', body: JSON.stringify(body), }); if (!resp.ok) { throw new Error(`Failed to query database: ${resp.status}`); } const data = JSON.parse(resp.body); const items = data.results.map(item => ({ id: item.id, url: item.url, properties: item.properties, })); return { success: true, action: 'queryDatabase', databaseId, count: items.length, items, }; } module.exports = { handler }; ``` ## Manifest ```json { "trigger": "auto", "oauth": { "provider": "custom", "providerName": "Notion", "authorizationUrl": "https://api.notion.com/v1/oauth/authorize", "tokenUrl": "https://api.notion.com/v1/oauth/token", "scopes": [], "clientIdSecret": "NOTION_CLIENT_ID", "clientSecretSecret": "NOTION_CLIENT_SECRET" }, "developerSecrets": { "NOTION_CLIENT_ID": { "description": "Notion OAuth Client ID from your integration", "required": true }, "NOTION_CLIENT_SECRET": { "description": "Notion OAuth Client Secret", "required": true } }, "tool": { "name": "notion", "description": "Search and read Notion pages and databases. Use when users ask about their Notion workspace.", "parameters": { "type": "object", "properties": { "action": { "type": "string", "enum": ["search", "getPage", "listDatabases", "queryDatabase"], "description": "The Notion action to perform" }, "query": { "type": "string", "description": "For search: text to search for" }, "filter": { "type": "string", "description": "For search: 'page' or 'database'. For queryDatabase: JSON filter object" }, "pageId": { "type": "string", "description": "For getPage: the page ID" }, "databaseId": { "type": "string", "description": "For queryDatabase: the database ID" } }, "required": ["action"] } } } ``` ## Setup 1. **Create Notion Integration** * Go to [Notion Integrations](https://www.notion.so/my-integrations) * Click "New Integration" * Name: "PIE Connector" (or your choice) * Select workspace * Enable capabilities: Read content, Read user info 2. **Enable OAuth** * In integration settings, go to "Distribution" * Enable "Public integration" * Add redirect URI: `https://your-pie-domain.com/api/oauth/plugin/{pluginId}/callback` * Copy Client ID and Secret 3. **Create Agent in PIE** * Type: Connector * Paste code and manifest * Add credentials as developer secrets 4. **User Connection** * When users connect, they select which pages to share * Integration only sees pages explicitly shared ## Key Patterns ### Custom OAuth Provider Notion doesn't have built-in support, so we specify URLs: ```json { "provider": "custom", "authorizationUrl": "https://api.notion.com/v1/oauth/authorize", "tokenUrl": "https://api.notion.com/v1/oauth/token" } ``` ### Required Headers Notion requires `Notion-Version` header: ```js async function notionFetch(context, endpoint, options = {}) { return context.oauth.fetch(url, { ...options, headers: { 'Content-Type': 'application/json', 'Notion-Version': '2022-06-28', ...options.headers, }, }); } ``` ### Permission Model Unlike Google, Notion uses a page-based permission model: * Users choose which pages to share during OAuth * Integrations only see explicitly shared content * No global access to workspace --- --- url: /docs/guides/oauth-basics.md --- # OAuth Basics OAuth allows your agent to access user data from services like Gmail, GitHub, and Notion without handling passwords. ## How It Works 1. User clicks "Connect" in PIE 2. PIE redirects to the service (e.g., Google) 3. User authorizes your agent 4. Service redirects back to PIE with a code 5. PIE exchanges code for tokens 6. PIE stores tokens securely (encrypted) 7. Your agent can now make authenticated requests ``` ┌──────┐ ┌─────┐ ┌─────────┐ │ User │────▶│ PIE │────▶│ Google │ └──────┘ └─────┘ └─────────┘ │ │ │ │ Click │ Redirect │ │ Connect │ to OAuth │ │ │ │ │ │◀────────────│ │ │ Auth code │ │ │ │ │ │────────────▶│ │ │ Exchange │ │ │ for tokens │ │ │ │ │ │◀────────────│ │ │ Tokens │ │ │ (stored) │ │ │ │ │ Ready! │ │ └────────────┘ │ ``` ## Setting Up OAuth ### 1. Create OAuth App Register your app with the service: | Service | Developer Console | |---------|-------------------| | Google | [Cloud Console](https://console.cloud.google.com/) | | GitHub | [Developer Settings](https://github.com/settings/developers) | | Notion | [Integrations](https://www.notion.so/my-integrations) | | Slack | [API Apps](https://api.slack.com/apps) | ### 2. Configure Redirect URI Set the callback URL in your OAuth app settings: ``` https://your-pie-domain.com/api/oauth/plugin/{pluginId}/callback ``` ### 3. Add OAuth Config to Manifest For built-in providers: ```json { "oauth": { "provider": "google", "scopes": ["https://www.googleapis.com/auth/gmail.readonly"], "clientIdSecret": "GOOGLE_CLIENT_ID", "clientSecretSecret": "GOOGLE_CLIENT_SECRET" } } ``` For custom providers: ```json { "oauth": { "provider": "custom", "providerName": "Notion", "authorizationUrl": "https://api.notion.com/v1/oauth/authorize", "tokenUrl": "https://api.notion.com/v1/oauth/token", "scopes": [], "clientIdSecret": "NOTION_CLIENT_ID", "clientSecretSecret": "NOTION_CLIENT_SECRET" } } ``` ### 4. Store Credentials Add your OAuth credentials as developer secrets: ```json { "developerSecrets": { "GOOGLE_CLIENT_ID": { "description": "Google OAuth Client ID", "required": true }, "GOOGLE_CLIENT_SECRET": { "description": "Google OAuth Client Secret", "required": true } } } ``` ## Using OAuth in Code ### Check Connection Always check if the user has connected: ```js async function handler(input, context) { const isConnected = await context.oauth.isConnected(); if (!isConnected) { return { error: true, message: 'Please connect the service first', requiresAuth: true, }; } // Continue with authenticated requests... } ``` ### Make Authenticated Requests Use `context.oauth.fetch()` instead of `context.fetch()`: ```js // PIE automatically adds: Authorization: Bearer {token} const response = await context.oauth.fetch( 'https://api.example.com/data' ); ``` The token is **never visible** to your code. PIE injects it automatically. ### Full Example ```js async function handler(input, context) { // Check connection if (!await context.oauth.isConnected()) { return { error: true, requiresAuth: true }; } // Make authenticated request const response = await context.oauth.fetch( 'https://api.github.com/user/repos', { headers: { 'Accept': 'application/vnd.github.v3+json' } } ); if (!response.ok) { return { error: true, message: `API error: ${response.status}` }; } const repos = JSON.parse(response.body); return { repos: repos.map(r => r.name) }; } ``` ## Token Refresh PIE handles token refresh automatically: 1. Before making a request, PIE checks if token is expired 2. If expired (or expiring soon), PIE uses refresh token 3. New access token is stored 4. Request proceeds with valid token You don't need to handle this in your code. ## Scopes Scopes define what your agent can access: ```json { "oauth": { "scopes": [ "https://www.googleapis.com/auth/gmail.readonly", "https://www.googleapis.com/auth/gmail.send" ] } } ``` **Best practice:** Request minimal scopes. Only ask for what you need. ## Security ### Token Isolation OAuth tokens are never exposed to your agent code: * `context.oauth.fetch()` - PIE adds the token * Tokens are encrypted at rest (AES-256-GCM) * Tokens are scoped to the agent that created them ### User Control Users can disconnect at any time: * Tokens are deleted from PIE * Revocation request sent to provider (if supported) * Agent loses access ## Common Issues ### "Not Connected" Error The user hasn't authorized yet. Return `requiresAuth: true`: ```js return { error: true, requiresAuth: true }; ``` ### Token Expired PIE auto-refreshes, but if refresh fails: * User needs to reconnect * Check if refresh token was revoked ### Wrong Scopes If you get 403 errors, you may need additional scopes: * Update manifest with required scopes * Users need to reconnect to grant new scopes --- --- url: /docs/reference/oauth-providers.md --- # OAuth Providers PIE supports both built-in OAuth providers and custom OAuth 2.0 services. ## Built-in Providers These providers have pre-configured OAuth URLs. You only need to provide scopes and credentials. ### Google ```json { "oauth": { "provider": "google", "scopes": [ "https://www.googleapis.com/auth/gmail.readonly", "https://www.googleapis.com/auth/gmail.send" ], "clientIdSecret": "GOOGLE_CLIENT_ID", "clientSecretSecret": "GOOGLE_CLIENT_SECRET" } } ``` **Common Scopes:** | Scope | Description | |-------|-------------| | `https://www.googleapis.com/auth/gmail.readonly` | Read Gmail | | `https://www.googleapis.com/auth/gmail.send` | Send emails | | `https://www.googleapis.com/auth/gmail.modify` | Read and modify | | `https://www.googleapis.com/auth/drive.readonly` | Read Drive files | | `https://www.googleapis.com/auth/calendar.readonly` | Read Calendar | | `https://www.googleapis.com/auth/calendar.events` | Manage events | **Setup:** 1. Go to [Google Cloud Console](https://console.cloud.google.com/) 2. Create a new project or select existing 3. Enable the APIs you need (Gmail API, etc.) 4. Go to **APIs & Services > Credentials** 5. Create an **OAuth 2.0 Client ID** 6. Add authorized redirect URI: `https://your-pie-domain.com/api/oauth/plugin/{pluginId}/callback` ### GitHub ```json { "oauth": { "provider": "github", "scopes": ["repo", "user"], "clientIdSecret": "GITHUB_CLIENT_ID", "clientSecretSecret": "GITHUB_CLIENT_SECRET" } } ``` **Common Scopes:** | Scope | Description | |-------|-------------| | `repo` | Full access to repositories | | `repo:status` | Read/write commit status | | `public_repo` | Public repos only | | `user` | Read user profile | | `user:email` | Read user email | | `read:org` | Read org membership | **Setup:** 1. Go to [GitHub Developer Settings](https://github.com/settings/developers) 2. Click **New OAuth App** 3. Set callback URL: `https://your-pie-domain.com/api/oauth/plugin/{pluginId}/callback` ### Slack ```json { "oauth": { "provider": "slack", "scopes": ["channels:read", "chat:write"], "clientIdSecret": "SLACK_CLIENT_ID", "clientSecretSecret": "SLACK_CLIENT_SECRET" } } ``` **Common Scopes:** | Scope | Description | |-------|-------------| | `channels:read` | View channels | | `channels:history` | Read channel messages | | `chat:write` | Send messages | | `users:read` | View users | | `files:read` | Access files | **Setup:** 1. Go to [Slack API Apps](https://api.slack.com/apps) 2. Click **Create New App** 3. Add OAuth scopes under **OAuth & Permissions** 4. Set redirect URL: `https://your-pie-domain.com/api/oauth/plugin/{pluginId}/callback` ### Notion ```json { "oauth": { "provider": "notion", "scopes": [], "clientIdSecret": "NOTION_CLIENT_ID", "clientSecretSecret": "NOTION_CLIENT_SECRET" } } ``` ::: info Notion doesn't use traditional scopes. Permissions are granted per-page by the user. ::: **Setup:** 1. Go to [Notion Integrations](https://www.notion.so/my-integrations) 2. Click **New Integration** 3. Enable **OAuth** under **Distribution** 4. Set redirect URI: `https://your-pie-domain.com/api/oauth/plugin/{pluginId}/callback` ## Custom Providers For any OAuth 2.0 service not listed above: ```json { "oauth": { "provider": "custom", "providerName": "Dropbox", "authorizationUrl": "https://www.dropbox.com/oauth2/authorize", "tokenUrl": "https://api.dropboxapi.com/oauth2/token", "userInfoUrl": "https://api.dropboxapi.com/2/users/get_current_account", "revokeUrl": "https://api.dropboxapi.com/2/auth/token/revoke", "scopes": ["files.content.read", "files.content.write"], "clientIdSecret": "DROPBOX_CLIENT_ID", "clientSecretSecret": "DROPBOX_CLIENT_SECRET" } } ``` ### Required Fields | Field | Description | |-------|-------------| | `provider` | Must be `"custom"` | | `providerName` | Display name for the service | | `authorizationUrl` | OAuth authorization endpoint | | `tokenUrl` | Token exchange endpoint | | `scopes` | Array of scope strings | | `clientIdSecret` | Key in developerSecrets for client ID | | `clientSecretSecret` | Key in developerSecrets for client secret | ### Optional Fields | Field | Description | |-------|-------------| | `userInfoUrl` | Endpoint to get user info (email, etc.) | | `revokeUrl` | Token revocation endpoint | ## Redirect URI When setting up your OAuth app, use this redirect URI pattern: ``` https://your-pie-domain.com/api/oauth/plugin/{pluginId}/callback ``` Where `{pluginId}` is your agent's UUID (available after creating the agent). ## Security Best Practices 1. **Never share credentials** - Keep client secrets secure 2. **Minimal scopes** - Request only what you need 3. **Handle revocation** - Respect when users disconnect 4. **Check connection** - Always verify `context.oauth.isConnected()` first ## Custom Provider Examples ### Dropbox ```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" } } ``` ### Spotify ```json { "oauth": { "provider": "custom", "providerName": "Spotify", "authorizationUrl": "https://accounts.spotify.com/authorize", "tokenUrl": "https://accounts.spotify.com/api/token", "userInfoUrl": "https://api.spotify.com/v1/me", "scopes": ["user-read-private", "user-read-email", "playlist-read-private"], "clientIdSecret": "SPOTIFY_CLIENT_ID", "clientSecretSecret": "SPOTIFY_CLIENT_SECRET" } } ``` ### Linear ```json { "oauth": { "provider": "custom", "providerName": "Linear", "authorizationUrl": "https://linear.app/oauth/authorize", "tokenUrl": "https://api.linear.app/oauth/token", "scopes": ["read", "write"], "clientIdSecret": "LINEAR_CLIENT_ID", "clientSecretSecret": "LINEAR_CLIENT_SECRET" } } ``` --- --- url: /docs/getting-started/overview.md --- # Overview PIE agents (sometimes called plugins) extend the capabilities of your Personal Intelligence Engine. Agents are modular - they can combine multiple features. ## Agent Features An agent can have any combination of these features: ### Skills (prompt text) Skills are custom prompts that are always active. They shape how PIE responds to you. * **No code required** - just text * **Always included** in conversations * **Use cases**: Personality customization, domain expertise, response formatting ### Tools (`manifest.tool`) Tools are functions that PIE can call during conversations. They enable PIE to take actions. * **Written in JavaScript** - runs in a secure sandbox * **Triggered automatically** - AI decides when to use them * **Use cases**: API integrations, data processing, external services ### Public Apps (`===public-file===` + `===public-config===`) Public apps let a plugin serve real web pages at shareable URLs. * **Normal browser frontend** - use HTML, CSS, JS, and the History API * **Hosted by PIE** - served at `/apps/{pluginSlug}/{instanceSlug}/...` or a custom domain * **Use cases**: Forms, landing pages, intake flows, customer portals, shareable dashboards * **Server-side actions**: call your plugin with `/api/public-actions/{instanceId}/{actionId}` ### OAuth (`manifest.oauth`) OAuth enables agents to access external services on behalf of the user. * **OAuth handled by PIE** - your code never sees tokens * **Secure token storage** - encrypted in database * **Use cases**: Gmail, Notion, Slack, any OAuth 2.0 provider ### Automations (`manifest.automation`) Automations run on schedules or in response to webhooks, with optional AI capabilities. * **Cron schedules** - run at specific times * **Webhooks** - respond to external events in real-time * **Lifecycle hooks** - run code when OAuth connects * **Heartbeats** - built-in scheduled messages to the AI (no code required) * **Task scheduling** - any agent can create reminders, crons, and heartbeats via `context.tasks` * **Use cases**: Daily summaries, real-time notifications, background sync, reminders ## Combining Features Agents can combine features. For example: * **Gmail Tool** - `tool` + `oauth`: AI can search/send emails * **Email Labeler** - `oauth` + `automation`: Auto-labels emails in background * **Weather Alert** - `tool` + `automation`: AI can check weather AND sends daily alerts * **Forms Builder** - `tool` + `widget` + `public app`: AI creates forms, the owner edits them in a widget, and respondents fill them out on a public page ## Agent Architecture ``` ┌─────────────────────────────────────────────────────────┐ │ PIE │ │ ┌─────────────────────────────────────────────────────┐ │ │ │ AI Model │ │ │ │ "I need to check the weather for this user..." │ │ │ └────────────────────────┬────────────────────────────┘ │ │ │ calls │ │ ┌────────────────────────▼────────────────────────────┐ │ │ │ Agent Sandbox (E2B Cloud / isolated-vm) │ │ │ │ ┌────────────────────────────────────────────────┐ │ │ │ │ │ Your Agent Code │ │ │ │ │ │ - handler(input, context) │ │ │ │ │ │ - context.fetch() for APIs │ │ │ │ │ │ - context.oauth.fetch() for OAuth APIs │ │ │ │ │ │ - context.secrets for API keys │ │ │ │ │ └────────────────────────────────────────────────┘ │ │ │ └─────────────────────────────────────────────────────┘ │ └─────────────────────────────────────────────────────────┘ ``` ## Security Model 1. **Sandboxed Execution**: Agent code runs in E2B Cloud Sandboxes (preferred) or isolated-vm (fallback) 2. **No Direct Access**: Agents cannot access the filesystem, database, or Node.js 3. **Proxied Requests**: `context.fetch()` goes through PIE's proxy 4. **Token Isolation**: OAuth tokens are never exposed to agent code 5. **Secrets Encryption**: All secrets are encrypted at rest (AES-256-GCM) ## Marketplace Tool Suggestions PIE proactively discovers relevant marketplace plugins during chat sessions. When a user sends a message, the system: 1. **Searches** a Gemini File Search vector store of all published plugin descriptions using RAG 2. **Filters** results with a fast LLM (`gpt-5-nano`) to keep only genuinely useful suggestions 3. **Injects** filtered suggestions into the primary LLM's system prompt so it can mention them naturally 4. **Displays** an inline install card in the chat UI so the user can install with one click This runs in parallel with all other context gathering (RAG, memory, skills, tools) so it adds no latency. The system is fully resilient: if the user dismisses or ignores a suggestion, the LLM continues normally. Pricing details are shown when applicable. **For plugin developers:** Write a clear `description` and meaningful `tags` in your manifest to maximize discoverability. See the [Manifest Reference](/reference/manifest) for details. ## What You'll Learn 1. **[Your First Skill](/getting-started/your-first-skill)** - Create a simple prompt-based skill 2. **[Your First Tool](/getting-started/your-first-tool)** - Build a tool that calls an API 3. **[Your First Public App](/getting-started/your-first-public-app)** - Build a shareable frontend with a widget, public page, and public actions 4. **[Your First Connector](/getting-started/your-first-connector)** - Create an OAuth connector 5. **[Your First Automation](/getting-started/your-first-automation)** - Set up a scheduled automation Ready to start? Let's [build your first skill](/getting-started/your-first-skill), jump to the [public app tutorial](/getting-started/your-first-public-app), or head to the [automation tutorial](/getting-started/your-first-automation). If you want a shareable frontend, read the [Public Apps and Routes guide](/guides/public-apps). --- --- url: /docs/guides/persistent-sandboxes.md --- # Persistent Sandboxes Persistent sandboxes let your agent maintain state across multiple user messages. Instead of creating and destroying a fresh sandbox on every invocation, the sandbox is **paused** after each execution and **resumed** on the next — preserving the filesystem, installed packages, cloned repos, and any running processes. ## When to Use Persistent Sandboxes Use persistent sandboxes when your agent needs to: * **Maintain filesystem state** — a coding agent that clones a repo and makes incremental changes * **Preserve authentication** — CLI tools that store credentials on disk (e.g., `gh auth`, `claude auth`) * **Accumulate context** — an agent that builds up a working environment over multiple interactions * **Run long-lived processes** — background services that the agent interacts with across messages **Don't use persistent sandboxes** for simple request/response tools (API calls, data lookups, one-shot transformations). The default ephemeral sandbox is faster and cheaper for these cases. ## How It Works ### Lifecycle ``` First message: CREATE sandbox → EXECUTE handler → PAUSE sandbox ↓ Second message: RESUME sandbox → EXECUTE handler → PAUSE sandbox ↓ Third message: RESUME sandbox → EXECUTE handler → PAUSE sandbox ↓ End session: RESUME sandbox → EXECUTE handler → KILL sandbox (handler sets requestKill: true) ``` ### What's Preserved | Preserved across pause/resume | Not preserved after kill | |-------------------------------|------------------------| | Filesystem (files, cloned repos) | Everything | | Installed packages | | | Auth credentials on disk | | | Environment variables | | | Session metadata (in DB) | Session metadata **is** preserved | ### Billing * **While running**: Standard E2B compute rates apply * **While paused**: Free — no compute charges * **Auto-cleanup**: Sessions paused longer than `maxSessionAge` are automatically killed ## Setup ### 1. Add `runtime.persistent` to Your Manifest ```yaml manifest: trigger: auto runtime: persistent: true timeoutMs: 300000 # 5 min per invocation maxSessionAge: 86400000 # auto-kill after 24 hours tool: name: my_agent description: An agent with persistent state parameters: type: object properties: prompt: type: string description: What to do required: [prompt] ``` ### 2. Use Session Metadata in Your Handler Session metadata lets your handler track state across invocations. It's stored in the database and available even after a sandbox is killed. ```js async function handler(input, context) { const meta = await context.getSessionMetadata(); if (!meta.initialized) { // First invocation — set up the environment // Clone repo, install dependencies, etc. await context.updateSessionMetadata({ initialized: true, setupAt: Date.now(), }); } // Do work... await context.updateSessionMetadata({ lastRun: Date.now(), totalRuns: (meta.totalRuns || 0) + 1, }); return { result: 'Done' }; } module.exports = { handler }; ``` ### 3. Implement Session End Let users end their session by setting the `requestKill` metadata flag: ```js if (input.action === 'end_session') { await context.updateSessionMetadata({ requestKill: true }); return { result: 'Session ended.' }; } ``` After execution completes, the platform will kill the sandbox instead of pausing it. ### 4. (Optional) Create a Custom Sandbox Template If your agent needs specific CLI tools or packages pre-installed, create a sandbox template: 1. Go to **Developer Portal → Templates tab** 2. Enter a setup script: ```bash npm install -g @anthropic-ai/claude-code apt-get install -y git curl jq mkdir -p /home/user/workspace ``` 3. Click **Create & Build Template** (builds in 1-3 minutes) 4. Assign the template to your agent in **Settings → Sandbox Template** ## Complete Example Here's a minimal persistent agent that maintains a counter across messages: ```yaml --- name: counter-agent displayName: Persistent Counter tier: tool version: 1.0.0 manifest: trigger: auto runtime: persistent: true timeoutMs: 60000 tool: name: counter description: A counter that persists across messages parameters: type: object properties: action: type: string enum: [increment, get, reset, end] description: What to do with the counter required: [action] --- async function handler(input, context) { const { action } = input; const meta = await context.getSessionMetadata(); if (action === 'end') { await context.updateSessionMetadata({ requestKill: true }); return { result: 'Counter session ended.', finalCount: meta.count || 0 }; } if (action === 'reset') { await context.updateSessionMetadata({ count: 0 }); return { result: 'Counter reset to 0.' }; } if (action === 'increment') { const newCount = (meta.count || 0) + 1; await context.updateSessionMetadata({ count: newCount }); return { result: `Counter incremented to ${newCount}.`, count: newCount }; } // action === 'get' return { count: meta.count || 0 }; } module.exports = { handler }; ``` ## Context Preservation When a session is killed (either by the user or by TTL), the sandbox filesystem is lost. However, session metadata persists in the database. Use this to restore context in new sessions: ```js const meta = await context.getSessionMetadata(); if (meta.previousSessionContext) { // Use the saved context to inform the new session console.log('Restoring context:', meta.previousSessionContext); } ``` **Before ending a session**, save a summary: ```js if (action === 'end_session') { await context.updateSessionMetadata({ requestKill: true, previousSessionContext: `Worked on repo ${meta.repoUrl}. Last task: ${meta.lastTask}.`, }); return { result: 'Session ended.' }; } ``` ## API Reference | API | Description | |-----|-------------| | [`context.getSessionMetadata()`](/reference/context-api#context-getsessionmetadata) | Read session metadata | | [`context.updateSessionMetadata(data)`](/reference/context-api#context-updatesessionmetadata) | Merge data into session metadata | | [`manifest.runtime.persistent`](/reference/manifest#runtime-configuration) | Enable persistent sandboxes | | [`manifest.runtime.timeoutMs`](/reference/manifest#runtime-configuration) | Per-invocation timeout | | [`manifest.runtime.maxSessionAge`](/reference/manifest#runtime-configuration) | Auto-cleanup TTL | --- --- url: /docs/llm-docs.md --- # 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=== ... ===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 | Trigger | Use 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 | Type | Options | |------|---------| | `text` | `placeholder` | | `number` | `min`, `max` | | `boolean` | - | | `select` | `options: [{ value, label }]` | | `tags` | `placeholder` | *** ## 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 ID | Name | Provider | |----------|------|----------| | `openai/gpt-5.4` | GPT-5.4 | OpenAI | | `google/gemini-3-flash-preview` | Gemini 3 Flash | Google | | `google/gemini-3-pro-preview` | Gemini 3 Pro | Google | | `google/gemini-3.1-pro-preview` | Gemini 3.1 Pro | Google | | `google/gemini-2.5-flash-lite` | Gemini 2.5 Flash Lite | Google | | `anthropic/claude-sonnet-4.6` | Claude Sonnet 4.6 | Anthropic | | `anthropic/claude-opus-4.6` | Claude Opus 4.6 | Anthropic | | `anthropic/claude-sonnet-4.5` | Claude Sonnet 4.5 | Anthropic | | `anthropic/claude-haiku-4.5` | Claude Haiku 4.5 | Anthropic | | `openai/gpt-5.2-chat` | GPT-5.2 | OpenAI | | `openai/gpt-5.2-pro` | GPT-5.2 Pro | OpenAI | | `openai/gpt-5.2-codex` | GPT-5.2 Codex | OpenAI | | `openai/gpt-5-mini` | GPT-5 Mini | OpenAI | | `openai/gpt-5-nano` | GPT-5 Nano | OpenAI | | `openai/gpt-4o` | GPT-4o | OpenAI | | `openai/gpt-4o-mini` | GPT-4o Mini | OpenAI | | `moonshotai/kimi-k2.5` | Kimi K2.5 | Moonshot | | `x-ai/grok-4.1-fast` | Grok 4.1 Fast | xAI | | `deepseek/deepseek-v3.2` | DeepSeek V3.2 | DeepSeek | | `minimax/minimax-m2.5` | MiniMax M2.5 | MiniMax | 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: | Variable | Value | Access with | |----------|-------|-------------| | `app.user_id` | End-user's PIE user ID | `current_setting('app.user_id')` | | `app.plugin_id` | Your plugin's ID | `current_setting('app.plugin_id')` | **Limits:** 30s statement timeout, 10,000 max rows, 5MB max response. | | `context.db.query()` | `context.managedDb.query()` | |---|---|---| | Database | External (you provide credentials) | PIE-managed (automatic) | | Access | Read-only | Full CRUD | | User context | None | `app.user_id` injected | | Use case | Querying user's own databases | Storing 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 | Expression | Meaning | |------------|---------| | `0 7 * * *` | Daily at 7am | | `*/15 * * * *` | Every 15 minutes | | `0 9 * * 1-5` | Weekdays 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 | Model | Field | Description | |-------|-------|-------------| | Install Price | `installPriceCents` | One-time fee on first install. Reinstalls are free. | | Monthly Subscription | `monthlyPriceCents` | Recurring 30-day charge. Auto-renewed. | | Per-Usage Fee | `perUsageMicrodollars` | Charged each execution. Supports `freeUsageQuota`. | | Custom Usage | `customUsageEnabled` | Lets 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 | | Heartbeats | Agent Automations | |---|---|---| | Code required | No | Yes (JavaScript) | | Created by | Users | Developers | | Trigger types | Interval, cron | Cron, webhook, interval | | AI access | Sends message to AI | Calls `context.ai` | | Infrastructure | `user_tasks` table | `user_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" ``` | Scenario | Action | |----------|--------| | Table in manifest but not in DB | `CREATE TABLE` | | Column in manifest but not on table | `ALTER TABLE ADD COLUMN` | | Table/column in DB but not in manifest | Ignored (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 | Limit | Value | |-------|-------| | Statement timeout | 30 seconds | | Max rows per query | 10,000 | | Max response size | 5 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] ``` 2. 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/resume | Not preserved after kill | |-------------------------------|------------------------| | Filesystem, packages, env vars | Everything (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. --- --- url: /docs/reference/connector-roadmap.md --- # PIE Connector Roadmap — Composio Parity Plan > **Goal:** Catalog every service that Composio integrates with, map out the PIE connector actions and heartbeat events we'd need for each, identify overlap with our existing connectors, and link to the official API docs. This is the master plan for building out PIE's connector ecosystem. *** ## Table of Contents * [How to Read This Document](#how-to-read-this-document) * [Existing PIE Connectors (Current State)](#existing-pie-connectors-current-state) * [Overlap Analysis](#overlap-analysis) * [Heartbeat Events Architecture](#heartbeat-events-architecture) * [Category 1 — Collaboration & Communication (53 services)](#category-1--collaboration--communication-53-services) * [Category 2 — Productivity & Project Management (146 services)](#category-2--productivity--project-management-146-services) * [Category 3 — CRM (45 services)](#category-3--crm-45-services) * [Category 4 — Developer Tools & DevOps (118 services)](#category-4--developer-tools--devops-118-services) * [Category 5 — Document & File Management (76 services)](#category-5--document--file-management-76-services) * [Category 6 — Finance & Accounting (51 services)](#category-6--finance--accounting-51-services) * [Category 7 — Marketing & Social Media (96 services)](#category-7--marketing--social-media-96-services) * [Category 8 — Sales & Customer Support (67 services)](#category-8--sales--customer-support-67-services) * [Category 9 — E-commerce (34 services)](#category-9--e-commerce-34-services) * [Category 10 — HR & Recruiting (12 services)](#category-10--hr--recruiting-12-services) * [Category 11 — Scheduling & Booking (19 services)](#category-11--scheduling--booking-19-services) * [Category 12 — AI & Machine Learning (79 services)](#category-12--ai--machine-learning-79-services) * [Category 13 — Analytics & Data (48 services)](#category-13--analytics--data-48-services) * [Category 14 — Design & Creative Tools (31 services)](#category-14--design--creative-tools-31-services) * [Category 15 — Education & LMS (14 services)](#category-15--education--lms-14-services) * [Category 16 — Entertainment & Media (~30 services)](#category-16--entertainment--media-30-services) * [Category 17 — Advertising & Marketing (16 services)](#category-17--advertising--marketing-16-services) * [Category 18 — Workflow Automation (35 services)](#category-18--workflow-automation-35-services) * [Category 19 — Social Media (~25 services)](#category-19--social-media-25-services) * [Category 20 — Data & Analytics (~20 services)](#category-20--data--analytics-20-services) * [Priority Tiers](#priority-tiers) * [Implementation Notes](#implementation-notes) *** ## How to Read This Document Each service entry follows this format: ``` ### Service Name - **Auth:** OAuth2 / API Key / Bearer Token / Basic - **Composio Toolkit:** `toolkit_slug` - **API Docs:** [link] - **PIE Status:** `exists` | `needs-update` | `new` - **Core Actions:** list of CRUD actions the connector should expose - **Auto Heartbeat Events:** events that fire automatically from tool/widget/webhook actions ``` **Heartbeat events** are the key differentiator — they let PIE users set up automated reactions ("when X happens in service Y, do Z"). Each connector should expose heartbeat events derived from: 1. **Webhook events** — declared in `automation.triggers[type=webhook].events` (auto-generated) 2. **Tool action completions** — `source: 'tool'` matchers on action names 3. **Widget actions** — `source: 'widget'` matchers on widget action IDs 4. **Public actions** — `source: 'public'` matchers for anonymous triggers *** ## Existing PIE Connectors (Current State) These are the connectors we already have as `.pie` plugins with `tier: connector`: | Plugin | Service | Auth Provider | Webhook Events | Notes | |--------|---------|---------------|----------------|-------| | `gmail-assistant` | Gmail | Google OAuth2 | — (polling) | Mature v3, inbox triage, labels, drafts, widget | | `github` | GitHub | Custom OAuth2 | push, pull\_request, issues, issue\_comment, PR review, workflow\_run, release, create, delete, star | Full webhook suite | | `hubspot-crm` | HubSpot | Custom OAuth2 | TBD | CRM connector | | `close-crm` | Close CRM | API Key | TBD | CRM connector | | `slack` | Slack | Slack OAuth2 | TBD | Messaging | | `notion` | Notion | Notion OAuth2 | TBD | Wiki/docs | | `asana` | Asana | Custom OAuth2 | TBD | Project management | | `spotify` | Spotify | Custom OAuth2 | — | Entertainment/music | | `dropbox` | Dropbox | Custom OAuth2 | TBD | Cloud storage | | `digitalocean` | DigitalOcean | Custom OAuth2 | TBD | Cloud infra | | `meta-ads` | Meta Ads | Custom OAuth2 | — | Ad management | | `x-twitter` | X / Twitter | Custom OAuth2 | — | Social media | | `google-calendar` | Google Calendar | Google OAuth2 | — (polling) | Calendar events | | `trello` | Trello | Custom OAuth2 | TBD | Kanban boards | **Server-side integrations** (not full connectors yet, but could be promoted): | Service | File | Notes | |---------|------|-------| | Telegram | `server/src/services/telegram.ts` | Bot integration | | Twilio | `server/src/services/twilio.ts` | SMS/voice | | Stripe | `server/src/services/stripe.ts` | Payments | **Other plugins with integration aspects** (tier: tool, not connector): | Plugin | Service | |--------|---------| | `linkedin-engagement` | LinkedIn | | `facebook-ad-library-research` | Facebook | | `instagram-researcher` | Instagram | | `reddit-researcher` | Reddit | | `tiktok-researcher` | TikTok | | `youtube-researcher` | YouTube | | `ahrefs-seo` | Ahrefs | | `google-maps` | Google Maps | | `postgres-analysis` | PostgreSQL | *** ## Overlap Analysis ### Already Built (Composio overlap — 13 services) These Composio services already have PIE connectors. For each, we note gaps to close: | Service | PIE Has | Composio Has | Gap | |---------|---------|-------------|-----| | **Gmail** | Full inbox triage, labels, drafts, widget | Send, read, list, search, attachments | PIE is MORE complete | | **GitHub** | Full CRUD, webhooks for 10+ events | Repos, issues, PRs, branches, releases | PIE is comparable | | **Slack** | Messaging connector | Channels, messages, users, reactions, files | Need to verify action coverage | | **Notion** | Pages/databases | Pages, databases, blocks, search, comments | Need to verify action coverage | | **HubSpot** | CRM connector | Contacts, deals, companies, tickets, pipelines | Need to verify action coverage | | **Asana** | Task management | Tasks, projects, sections, tags, teams | Need to verify action coverage | | **Google Calendar** | Event scheduling | Events, calendars, free/busy | PIE is comparable | | **Dropbox** | File management | Files, folders, sharing, search | Need to verify action coverage | | **X (Twitter)** | Posts, timelines, DMs | Tweets, timelines, users, DMs, lists | Need to verify action coverage | | **Meta Ads** | Ad management | Campaigns, ad sets, ads, insights | Need to verify action coverage | | **Spotify** | Music connector | Playback, playlists, search, library | Need to verify action coverage | | **Trello** | Kanban boards | Boards, lists, cards, members, labels | Need to verify action coverage | | **Close CRM** | CRM connector | Leads, contacts, opportunities, activities | Need to verify action coverage | ### Research-Only (have tool plugins, need full connectors — 7 services) | Service | Current PIE Plugin | Needs | |---------|-------------------|-------| | **LinkedIn** | `linkedin-engagement` (tool) | Full OAuth connector with posts, connections, company pages | | **Reddit** | `reddit-researcher` (tool) | Full OAuth connector with posts, comments, subreddits | | **Instagram** | `instagram-researcher` (tool) | Full Graph API connector for business accounts | | **YouTube** | `youtube-researcher` (tool) | Full OAuth connector with channel management, uploads | | **Facebook** | `facebook-ad-library-research` (tool) | Full Graph API connector for pages, posts, groups | | **TikTok** | `tiktok-researcher` (tool) | Full connector with posting, analytics | | **Ahrefs** | `ahrefs-seo` (tool) | Full connector with site audit, keywords, backlinks | ### Server-Side (need promotion to full connectors — 3 services) | Service | Current | Needs | |---------|---------|-------| | **Stripe** | Server service | Full connector with payments, customers, subscriptions, invoices, webhooks | | **Twilio** | Server service | Full connector with SMS, voice, WhatsApp, webhooks | | **Telegram** | Server service | Full connector with messages, groups, channels, bots, webhooks | *** ## Heartbeat Events Architecture Every PIE connector should automatically expose heartbeat events. Here's how they work and what every connector should define: ### Automatic Heartbeat Event Sources ```yaml heartbeatEvents: events: # 1. WEBHOOK EVENTS — auto-generated from automation.triggers[type=webhook].events # When a webhook fires, it becomes a subscribable heartbeat event automatically. # Connector authors just need to declare webhook events in the manifest. # 2. TOOL ACTION EVENTS — fire when an AI tool action completes - id: email_sent displayName: Email Sent description: Fires when an email is successfully sent matchers: - source: tool action: send_email # 3. WIDGET ACTION EVENTS — fire when a user performs an action in the widget UI - id: label_applied displayName: Label Applied description: Fires when a user applies a label in the widget matchers: - source: widget actionId: apply_label # 4. PUBLIC ACTION EVENTS — fire from anonymous/external triggers - id: form_submitted displayName: Form Submitted matchers: - source: public actionId: submit_form ``` ### Standard Heartbeat Events Per Connector Type Every connector should expose at minimum these heartbeat event patterns (where applicable): | Connector Type | Standard Heartbeat Events | |---------------|--------------------------| | **Email** | `email_received`, `email_sent`, `email_labeled`, `email_archived`, `email_replied` | | **CRM** | `contact_created`, `contact_updated`, `deal_created`, `deal_stage_changed`, `deal_won`, `deal_lost`, `note_added` | | **Project Management** | `task_created`, `task_completed`, `task_assigned`, `comment_added`, `project_updated`, `status_changed` | | **Messaging** | `message_received`, `message_sent`, `channel_created`, `reaction_added`, `mention_received` | | **Source Control** | `push_received`, `pr_opened`, `pr_merged`, `issue_opened`, `issue_closed`, `review_submitted`, `workflow_completed` | | **Calendar** | `event_created`, `event_updated`, `event_cancelled`, `event_starting_soon`, `attendee_responded` | | **Storage** | `file_uploaded`, `file_shared`, `file_modified`, `file_deleted`, `folder_created` | | **Social Media** | `post_published`, `comment_received`, `mention_received`, `follower_gained`, `dm_received` | | **E-commerce** | `order_placed`, `order_shipped`, `order_completed`, `payment_received`, `refund_issued`, `inventory_low` | | **Finance** | `payment_received`, `invoice_sent`, `invoice_paid`, `subscription_created`, `subscription_cancelled` | | **HR** | `application_received`, `candidate_stage_changed`, `offer_sent`, `employee_onboarded` | | **Analytics** | `threshold_exceeded`, `report_generated`, `anomaly_detected` | *** ## Category 1 — Collaboration & Communication (53 services) ### Gmail * **Auth:** OAuth2, Bearer Token * **Composio Toolkit:** `gmail` * **API Docs:** [Gmail API](https://developers.google.com/gmail/api/reference/rest) * **PIE Status:** `exists` — `gmail-assistant.pie` (v3, mature) * **Core Actions:** list\_messages, get\_message, send\_email, create\_draft, list\_labels, create\_label, modify\_message, search\_messages, get\_thread, list\_threads, get\_attachment * **Auto Heartbeat Events:** * `email_received` (webhook/polling) * `email_sent` (tool: send\_email) * `email_labeled` (tool: modify\_message) * `email_archived` (tool: modify\_message) * `draft_created` (tool: create\_draft) * **Gap:** None significant. PIE is ahead here. ### Outlook * **Auth:** OAuth2 * **Composio Toolkit:** `outlook` * **API Docs:** [Microsoft Graph Mail API](https://learn.microsoft.com/en-us/graph/api/resources/mail-api-overview) * **PIE Status:** `new` * **Core Actions:** list\_messages, get\_message, send\_email, create\_draft, reply\_to\_message, forward\_message, list\_folders, create\_folder, move\_message, search\_messages, list\_attachments, get\_attachment, create\_rule, list\_calendar\_events * **Auto Heartbeat Events:** * `email_received` (webhook) * `email_sent` (tool: send\_email) * `email_replied` (tool: reply\_to\_message) * `email_moved` (tool: move\_message) ### Slack * **Auth:** OAuth2, Bearer Token * **Composio Toolkit:** `slack` * **API Docs:** [Slack Web API](https://api.slack.com/methods) * **PIE Status:** `exists` — `slack.pie` * **Core Actions:** send\_message, list\_channels, get\_channel\_history, create\_channel, invite\_to\_channel, set\_topic, upload\_file, add\_reaction, remove\_reaction, list\_users, get\_user\_info, search\_messages, update\_message, delete\_message, pin\_message, list\_pins, set\_status * **Auto Heartbeat Events:** * `message_received` (webhook: message) * `message_sent` (tool: send\_message) * `channel_created` (webhook: channel\_created) * `reaction_added` (webhook: reaction\_added) * `mention_received` (webhook: app\_mention) * `file_shared` (webhook: file\_shared) * `member_joined` (webhook: member\_joined\_channel) * **Gap:** Verify webhook event declarations in manifest. Add reaction, pin, status actions if missing. ### Microsoft Teams * **Auth:** OAuth2 * **Composio Toolkit:** `microsoft_teams` * **API Docs:** [Microsoft Graph Teams API](https://learn.microsoft.com/en-us/graph/api/resources/teams-api-overview) * **PIE Status:** `new` * **Core Actions:** send\_message, list\_channels, create\_channel, list\_teams, get\_channel\_messages, reply\_to\_message, list\_members, add\_member, create\_meeting, list\_chats, get\_chat\_messages * **Auto Heartbeat Events:** * `message_received` (webhook) * `message_sent` (tool: send\_message) * `channel_created` (webhook) * `member_added` (webhook) * `meeting_created` (tool: create\_meeting) ### Discord * **Auth:** OAuth2, Bearer Token * **Composio Toolkit:** `discord` * **API Docs:** [Discord API](https://discord.com/developers/docs/intro) * **PIE Status:** `new` * **Core Actions:** send\_message, list\_channels, create\_channel, list\_guilds, get\_messages, add\_reaction, create\_thread, list\_members, ban\_member, kick\_member, manage\_roles, create\_webhook * **Auto Heartbeat Events:** * `message_received` (webhook: MESSAGE\_CREATE) * `message_sent` (tool: send\_message) * `member_joined` (webhook: GUILD\_MEMBER\_ADD) * `reaction_added` (webhook: MESSAGE\_REACTION\_ADD) ### Gong * **Auth:** OAuth2 * **Composio Toolkit:** `gong` * **API Docs:** [Gong API](https://gong.app.gong.io/settings/api/documentation) * **PIE Status:** `new` * **Core Actions:** list\_calls, get\_call, get\_call\_transcript, list\_users, get\_user, search\_calls, get\_call\_stats, list\_scorecards, get\_deal\_info * **Auto Heartbeat Events:** * `call_recorded` (webhook) * `call_analyzed` (webhook) * `deal_updated` (webhook) ### Confluence * **Auth:** OAuth2 * **Composio Toolkit:** `confluence` * **API Docs:** [Confluence REST API](https://developer.atlassian.com/cloud/confluence/rest/v2/intro/) * **PIE Status:** `new` * **Core Actions:** get\_page, create\_page, update\_page, delete\_page, list\_pages, search\_content, get\_space, list\_spaces, create\_space, add\_comment, list\_comments, get\_attachments, upload\_attachment * **Auto Heartbeat Events:** * `page_created` (webhook: page\_created) * `page_updated` (webhook: page\_updated) * `comment_added` (webhook: comment\_created) ### Google Meet * **Auth:** OAuth2 * **Composio Toolkit:** `googlemeet` * **API Docs:** [Google Meet REST API](https://developers.google.com/meet/api/reference/rest) * **PIE Status:** `new` * **Core Actions:** create\_meeting, get\_meeting, list\_recordings, get\_transcript, list\_participants * **Auto Heartbeat Events:** * `meeting_started` (webhook) * `meeting_ended` (webhook) * `recording_available` (webhook) ### Basecamp * **Auth:** OAuth2 * **Composio Toolkit:** `basecamp` * **API Docs:** [Basecamp 4 API](https://github.com/basecamp/bc3-api) * **PIE Status:** `new` * **Core Actions:** list\_projects, create\_project, list\_todos, create\_todo, complete\_todo, post\_message, list\_messages, upload\_file, create\_event, list\_people * **Auto Heartbeat Events:** * `todo_completed` (tool: complete\_todo) * `message_posted` (tool: post\_message) * `todo_created` (tool: create\_todo) ### Dialpad * **Auth:** OAuth2, API Key * **Composio Toolkit:** `dialpad` * **API Docs:** [Dialpad API](https://developers.dialpad.com/reference) * **PIE Status:** `new` * **Core Actions:** make\_call, send\_sms, list\_contacts, get\_call\_log, list\_users, create\_contact, get\_recording * **Auto Heartbeat Events:** * `call_completed` (webhook) * `sms_received` (webhook) * `voicemail_received` (webhook) ### Intercom * **Auth:** OAuth2 * **Composio Toolkit:** `intercom` * **API Docs:** [Intercom API](https://developers.intercom.com/docs/build-an-integration/learn-more/rest-apis/) * **PIE Status:** `new` * **Core Actions:** list\_contacts, create\_contact, update\_contact, search\_contacts, list\_conversations, get\_conversation, reply\_to\_conversation, create\_message, list\_tags, tag\_contact, create\_note * **Auto Heartbeat Events:** * `conversation_created` (webhook: conversation.created) * `conversation_replied` (webhook: conversation.user.replied) * `contact_created` (webhook: contact.created) * `conversation_closed` (webhook: conversation.admin.closed) ### Twilio (promote from server service) * **Auth:** API Key / Basic * **Composio Toolkit:** `twilio` (not listed in Composio but common) * **API Docs:** [Twilio REST API](https://www.twilio.com/docs/usage/api) * **PIE Status:** `needs-update` — exists as server service, needs full connector * **Core Actions:** send\_sms, send\_whatsapp, make\_call, list\_messages, get\_message, list\_calls, get\_call, create\_conference, list\_recordings * **Auto Heartbeat Events:** * `sms_received` (webhook: incoming-message) * `call_received` (webhook: incoming-call) * `sms_sent` (tool: send\_sms) * `call_completed` (webhook: call-completed) ### Telegram (promote from server service) * **Auth:** Bot Token * **Composio Toolkit:** `telegram` (not listed separately) * **API Docs:** [Telegram Bot API](https://core.telegram.org/bots/api) * **PIE Status:** `needs-update` — exists as server service, needs full connector * **Core Actions:** send\_message, edit\_message, delete\_message, forward\_message, send\_photo, send\_document, get\_updates, get\_chat, get\_chat\_members, pin\_message, create\_poll, answer\_callback\_query * **Auto Heartbeat Events:** * `message_received` (webhook: message) * `message_sent` (tool: send\_message) * `callback_query` (webhook: callback\_query) * `member_joined` (webhook: new\_chat\_members) ### Additional Communication Services (new) | Service | Auth | API Docs | Core Actions | Key Heartbeats | |---------|------|----------|--------------|----------------| | **Slackbot** | OAuth2 | [Slack Events API](https://api.slack.com/events) | bot\_respond, schedule\_message, manage\_reminders | `reminder_fired`, `bot_mentioned` | | **2chat** | API Key | [2chat API](https://docs.2chat.co/) | send\_whatsapp, send\_sms, list\_chats, list\_messages | `message_received`, `message_sent` | | **Agent Mail** | API Key | [Agent Mail API](https://docs.agentmail.to/) | create\_inbox, send\_email, list\_emails, get\_email | `email_received`, `email_sent` | | **Chatwork** | API Key | [Chatwork API](https://developer.chatwork.com/docs) | send\_message, list\_rooms, get\_messages, list\_tasks | `message_received`, `task_created` | | **DailyBot** | API Key | [DailyBot API](https://docs.dailybot.com/) | create\_standup, get\_responses, create\_poll | `standup_completed`, `poll_response` | | **Loomio** | API Key | [Loomio API](https://help.loomio.com/en/dev_manual/) | create\_discussion, create\_proposal, list\_groups | `proposal_closed`, `vote_cast` | | **MailerSend** | API Key | [MailerSend API](https://developers.mailersend.com/) | send\_email, list\_templates, get\_activity | `email_delivered`, `email_bounced`, `email_opened` | | **Missive** | API Key | [Missive API](https://missiveapp.com/help/api-documentation/) | list\_conversations, send\_message, assign\_conversation | `conversation_assigned`, `message_received` | | **MSG91** | API Key | [MSG91 API](https://docs.msg91.com/) | send\_sms, send\_otp, verify\_otp | `sms_delivered`, `otp_verified` | | **Echtpost** | API Key | [Echtpost API](https://echtpost.de/api) | send\_document, track\_delivery | `document_delivered` | | **Egnyte** | OAuth2 | [Egnyte API](https://developers.egnyte.com/) | upload\_file, list\_files, share\_file, create\_folder | `file_uploaded`, `file_shared` | | **ClickMeeting** | API Key | [ClickMeeting API](https://clickmeeting.com/api/) | create\_webinar, list\_webinars, get\_attendees | `webinar_started`, `attendee_joined` | *** ## Category 2 — Productivity & Project Management (146 services) ### Notion * **Auth:** OAuth2, API Key * **Composio Toolkit:** `notion` * **API Docs:** [Notion API](https://developers.notion.com/reference) * **PIE Status:** `exists` — `notion.pie` * **Core Actions:** search, get\_page, create\_page, update\_page, get\_database, query\_database, create\_database, update\_database, get\_block, update\_block, append\_blocks, delete\_block, list\_comments, create\_comment, list\_users, get\_user * **Auto Heartbeat Events:** * `page_created` (tool: create\_page) * `page_updated` (tool: update\_page) * `database_entry_added` (tool: create\_page in database) * `comment_added` (tool: create\_comment) * **Gap:** Verify all actions are exposed. Add webhook support if Notion supports it. ### Asana * **Auth:** OAuth2 * **Composio Toolkit:** `asana` * **API Docs:** [Asana API](https://developers.asana.com/reference) * **PIE Status:** `exists` — `asana.pie` * **Core Actions:** list\_tasks, create\_task, update\_task, complete\_task, list\_projects, create\_project, list\_sections, create\_section, move\_task, add\_comment, list\_tags, add\_tag, list\_teams, get\_user, search\_tasks * **Auto Heartbeat Events:** * `task_created` (webhook: task.created) * `task_completed` (webhook: task.completed) * `task_assigned` (webhook: task.assigned) * `comment_added` (webhook: story.created) * `project_updated` (webhook: project.changed) * **Gap:** Verify webhook events are declared in manifest. ### Google Sheets * **Auth:** OAuth2 * **Composio Toolkit:** `googlesheets` * **API Docs:** [Google Sheets API](https://developers.google.com/sheets/api/reference/rest) * **PIE Status:** `new` * **Core Actions:** get\_spreadsheet, create\_spreadsheet, get\_values, update\_values, append\_values, clear\_values, batch\_get, batch\_update, create\_sheet, delete\_sheet, copy\_sheet, get\_metadata * **Auto Heartbeat Events:** * `values_updated` (tool: update\_values) * `row_appended` (tool: append\_values) * `sheet_created` (tool: create\_sheet) ### Airtable * **Auth:** OAuth2, Bearer Token, API Key * **Composio Toolkit:** `airtable` * **API Docs:** [Airtable API](https://airtable.com/developers/web/api/introduction) * **PIE Status:** `new` * **Core Actions:** list\_records, get\_record, create\_record, update\_record, delete\_record, list\_bases, get\_base\_schema, list\_tables, create\_table, list\_views, create\_field * **Auto Heartbeat Events:** * `record_created` (webhook: record.created) * `record_updated` (webhook: record.updated) * `record_deleted` (webhook: record.deleted) ### Jira * **Auth:** OAuth2, API Key * **Composio Toolkit:** `jira` * **API Docs:** [Jira REST API](https://developer.atlassian.com/cloud/jira/platform/rest/v3/intro/) * **PIE Status:** `new` * **Core Actions:** get\_issue, create\_issue, update\_issue, transition\_issue, assign\_issue, add\_comment, list\_comments, search\_issues (JQL), list\_projects, get\_project, list\_boards, list\_sprints, get\_sprint, move\_to\_sprint, list\_transitions, get\_worklog, add\_worklog * **Auto Heartbeat Events:** * `issue_created` (webhook: jira:issue\_created) * `issue_updated` (webhook: jira:issue\_updated) * `issue_transitioned` (tool: transition\_issue) * `comment_added` (webhook: comment\_created) * `sprint_started` (webhook: sprint\_started) * `sprint_completed` (webhook: sprint\_closed) ### Linear * **Auth:** OAuth2, API Key * **Composio Toolkit:** `linear` * **API Docs:** [Linear API (GraphQL)](https://developers.linear.app/docs/graphql/working-with-the-graphql-api) * **PIE Status:** `new` * **Core Actions:** list\_issues, create\_issue, update\_issue, get\_issue, list\_projects, create\_project, list\_teams, list\_cycles, create\_comment, list\_labels, assign\_issue, change\_status, search\_issues * **Auto Heartbeat Events:** * `issue_created` (webhook: Issue.create) * `issue_updated` (webhook: Issue.update) * `issue_status_changed` (webhook) * `comment_added` (webhook: Comment.create) * `cycle_completed` (webhook) ### ClickUp * **Auth:** OAuth2, API Key * **Composio Toolkit:** `clickup` * **API Docs:** [ClickUp API](https://clickup.com/api) * **PIE Status:** `new` * **Core Actions:** list\_tasks, create\_task, update\_task, get\_task, delete\_task, list\_spaces, list\_folders, list\_lists, create\_list, add\_comment, list\_comments, get\_time\_entries, create\_time\_entry, set\_priority, add\_tag * **Auto Heartbeat Events:** * `task_created` (webhook: taskCreated) * `task_updated` (webhook: taskUpdated) * `task_completed` (webhook: taskStatusUpdated) * `comment_added` (webhook: taskCommentPosted) ### Monday.com * **Auth:** OAuth2 * **Composio Toolkit:** `monday` * **API Docs:** [Monday.com API (GraphQL)](https://developer.monday.com/api-reference/docs) * **PIE Status:** `new` * **Core Actions:** list\_boards, get\_board, create\_item, update\_item, get\_item, list\_items, add\_update, list\_columns, change\_column\_value, create\_group, list\_workspaces * **Auto Heartbeat Events:** * `item_created` (webhook: create\_item) * `item_updated` (webhook: change\_column\_value) * `status_changed` (webhook: change\_status\_column\_value) * `update_posted` (webhook: create\_update) ### Google Tasks * **Auth:** OAuth2 * **Composio Toolkit:** `googletasks` * **API Docs:** [Google Tasks API](https://developers.google.com/tasks/reference/rest) * **PIE Status:** `new` * **Core Actions:** list\_task\_lists, create\_task\_list, list\_tasks, create\_task, update\_task, complete\_task, delete\_task, move\_task * **Auto Heartbeat Events:** * `task_completed` (tool: complete\_task) * `task_created` (tool: create\_task) ### Trello * **Auth:** OAuth2 * **Composio Toolkit:** `trello` (not listed separately in Composio) * **API Docs:** [Trello REST API](https://developer.atlassian.com/cloud/trello/rest/) * **PIE Status:** `exists` — `trello.pie` * **Core Actions:** list\_boards, get\_board, list\_lists, create\_list, list\_cards, create\_card, update\_card, move\_card, add\_comment, list\_members, add\_label, archive\_card * **Auto Heartbeat Events:** * `card_created` (webhook: createCard) * `card_moved` (webhook: updateCard) * `comment_added` (webhook: commentCard) * `card_archived` (tool: archive\_card) ### Additional Productivity Services (new) | Service | Auth | API Docs | Core Actions | Key Heartbeats | |---------|------|----------|--------------|----------------| | **Coda** | API Key | [Coda API](https://coda.io/developers/apis/v1) | list\_docs, get\_doc, list\_tables, list\_rows, add\_row, update\_row | `row_added`, `row_updated` | | **Clockify** | API Key, OAuth2 | [Clockify API](https://docs.clockify.me/) | list\_time\_entries, create\_entry, stop\_timer, list\_projects | `timer_stopped`, `entry_created` | | **Baserow** | Bearer | [Baserow API](https://baserow.io/api-docs) | list\_rows, create\_row, update\_row, delete\_row, list\_tables | `row_created`, `row_updated` | | **Canny** | API Key | [Canny API](https://developers.canny.io/api-reference) | list\_posts, create\_post, list\_comments, change\_status | `post_created`, `status_changed`, `vote_cast` | | **Dart** | OAuth2 | [Dart API](https://docs.itsdart.com/api) | list\_tasks, create\_task, update\_task, list\_projects | `task_created`, `task_completed` | | **Hive** | API Key | [Hive API](https://developers.hive.com/) | list\_actions, create\_action, update\_action, list\_projects | `action_completed`, `action_created` | | **Wrike** | OAuth2 | [Wrike API](https://developers.wrike.com/) | list\_tasks, create\_task, update\_task, list\_folders | `task_created`, `task_completed`, `task_assigned` | | **Todoist** | OAuth2 | [Todoist API](https://developer.todoist.com/rest/v2/) | list\_tasks, create\_task, complete\_task, list\_projects | `task_completed`, `task_created` | | **Smartsheet** | OAuth2 | [Smartsheet API](https://smartsheet.redoc.ly/) | list\_sheets, get\_sheet, list\_rows, add\_row, update\_row | `row_added`, `row_updated` | *** ## Category 3 — CRM (45 services) ### HubSpot * **Auth:** OAuth2, Bearer Token * **Composio Toolkit:** `hubspot` * **API Docs:** [HubSpot API](https://developers.hubspot.com/docs/api/overview) * **PIE Status:** `exists` — `hubspot-crm.pie` * **Core Actions:** list\_contacts, create\_contact, update\_contact, get\_contact, search\_contacts, list\_deals, create\_deal, update\_deal, list\_companies, create\_company, update\_company, create\_engagement, list\_pipelines, list\_deal\_stages, create\_ticket, list\_tickets, list\_owners, associate\_objects * **Auto Heartbeat Events:** * `contact_created` (webhook: contact.creation) * `contact_updated` (webhook: contact.propertyChange) * `deal_created` (webhook: deal.creation) * `deal_stage_changed` (webhook: deal.propertyChange) * `deal_won` (custom matcher on deal stage) * `ticket_created` (webhook: ticket.creation) * **Gap:** Verify webhook event declarations. Add pipeline/stage heartbeats. ### Salesforce * **Auth:** OAuth2 * **Composio Toolkit:** `salesforce` * **API Docs:** [Salesforce REST API](https://developer.salesforce.com/docs/atlas.en-us.api_rest.meta/api_rest/) * **PIE Status:** `new` * **Core Actions:** query (SOQL), get\_record, create\_record, update\_record, delete\_record, describe\_object, list\_objects, search (SOSL), get\_report, create\_lead, convert\_lead, list\_opportunities, update\_opportunity * **Auto Heartbeat Events:** * `lead_created` (webhook: Lead.created) * `opportunity_created` (webhook: Opportunity.created) * `opportunity_stage_changed` (webhook: Opportunity.updated) * `case_created` (webhook: Case.created) * `deal_closed_won` (custom) ### Pipedrive * **Auth:** OAuth2, API Key * **Composio Toolkit:** `pipedrive` * **API Docs:** [Pipedrive API](https://developers.pipedrive.com/docs/api/v1) * **PIE Status:** `new` * **Core Actions:** list\_deals, create\_deal, update\_deal, get\_deal, list\_persons, create\_person, update\_person, list\_organizations, create\_organization, list\_activities, create\_activity, list\_pipelines, list\_stages, add\_note, search * **Auto Heartbeat Events:** * `deal_created` (webhook: added.deal) * `deal_updated` (webhook: updated.deal) * `deal_won` (webhook: updated.deal with status=won) * `person_created` (webhook: added.person) * `activity_completed` (webhook: updated.activity) ### Close CRM * **Auth:** API Key * **Composio Toolkit:** `close` * **API Docs:** [Close API](https://developer.close.com/) * **PIE Status:** `exists` — `close-crm.pie` * **Core Actions:** list\_leads, create\_lead, update\_lead, search\_leads, list\_contacts, create\_contact, list\_opportunities, create\_opportunity, update\_opportunity, log\_activity, list\_activities, send\_email, list\_email\_templates, create\_task * **Auto Heartbeat Events:** * `lead_created` (webhook: lead.created) * `opportunity_created` (webhook: opportunity.created) * `opportunity_status_changed` (webhook: opportunity.status\_changed) * `email_received` (webhook: email.incoming) * `task_completed` (webhook: task.completed) * **Gap:** Verify webhook support in manifest. ### Additional CRM Services (new) | Service | Auth | API Docs | Core Actions | Key Heartbeats | |---------|------|----------|--------------|----------------| | **Apollo** | API Key | [Apollo API](https://apolloio.github.io/apollo-api-docs/) | search\_people, enrich\_contact, create\_sequence, list\_sequences | `contact_enriched`, `sequence_completed` | | **Attio** | OAuth2 | [Attio API](https://developers.attio.com/) | list\_records, create\_record, update\_record, search | `record_created`, `record_updated` | | **Dynamics 365** | OAuth2 | [Dynamics 365 API](https://learn.microsoft.com/en-us/dynamics365/customer-engagement/web-api/overview) | list\_entities, create\_record, update\_record, query | `lead_created`, `opportunity_updated` | | **Capsule CRM** | OAuth2, API Key | [Capsule API](https://developer.capsulecrm.com/) | list\_parties, create\_party, list\_opportunities, create\_opportunity | `party_created`, `opportunity_won` | | **Affinity** | API Key | [Affinity API](https://api-docs.affinity.co/) | list\_persons, create\_person, list\_organizations, list\_lists | `person_added`, `note_created` | | **Folk** | API Key | [Folk API](https://docs.folk.app/) | list\_contacts, create\_contact, list\_groups | `contact_added`, `contact_updated` | | **Freshsales** | API Key | [Freshsales API](https://developers.freshworks.com/crm/api/) | list\_contacts, create\_contact, list\_deals, create\_deal | `deal_created`, `contact_created` | | **Kommo** | OAuth2 | [Kommo API](https://www.kommo.com/developers/content/api/) | list\_leads, create\_lead, list\_contacts, list\_pipelines | `lead_created`, `lead_status_changed` | | **Zoho CRM** | OAuth2 | [Zoho CRM API](https://www.zoho.com/crm/developer/docs/api/v6/) | list\_records, create\_record, update\_record, search | `record_created`, `deal_stage_changed` | | **Copper** | API Key | [Copper API](https://developer.copper.com/) | list\_leads, create\_lead, list\_opportunities, list\_people | `lead_created`, `opportunity_won` | | **Blackbaud** | OAuth2 | [Blackbaud SKY API](https://developer.blackbaud.com/skyapi) | list\_constituents, create\_constituent, list\_gifts, create\_gift | `gift_received`, `constituent_created` | | **Follow Up Boss** | Basic, OAuth2 | [FUB API](https://docs.followupboss.com/) | list\_people, create\_person, list\_deals, create\_deal | `lead_created`, `deal_stage_changed` | | **NetHunt CRM** | Basic | [NetHunt API](https://nethunt.com/integration/api) | list\_records, create\_record, update\_record, search | `record_created`, `record_updated` | *** ## Category 4 — Developer Tools & DevOps (118 services) ### GitHub * **Auth:** OAuth2, Service Account * **Composio Toolkit:** `github` * **API Docs:** [GitHub REST API](https://docs.github.com/en/rest) * **PIE Status:** `exists` — `github.pie` (mature, full webhook support) * **Core Actions:** list\_repos, get\_repo, create\_repo, list\_issues, create\_issue, update\_issue, list\_pull\_requests, create\_pull\_request, merge\_pull\_request, list\_branches, create\_branch, list\_commits, get\_commit, list\_releases, create\_release, list\_workflows, trigger\_workflow, search\_code, list\_gists, create\_gist, get\_file\_content, list\_notifications * **Auto Heartbeat Events:** * `push` (webhook) — commits pushed * `pull_request` (webhook) — PR opened/closed/merged * `issues` (webhook) — issue opened/closed * `issue_comment` (webhook) — comment added * `pull_request_review` (webhook) — review submitted * `workflow_run` (webhook) — CI/CD completed * `release` (webhook) — release published * `create` (webhook) — branch/tag created * `delete` (webhook) — branch/tag deleted * `star` (webhook) — repo starred * **Gap:** None. PIE is ahead here. ### Supabase * **Auth:** OAuth2, API Key * **Composio Toolkit:** `supabase` * **API Docs:** [Supabase API](https://supabase.com/docs/guides/api) * **PIE Status:** `new` * **Core Actions:** query\_table, insert\_row, update\_row, delete\_row, list\_tables, run\_sql, manage\_auth\_users, list\_buckets, upload\_file, invoke\_edge\_function * **Auto Heartbeat Events:** * `row_inserted` (webhook: INSERT) * `row_updated` (webhook: UPDATE) * `row_deleted` (webhook: DELETE) * `auth_user_created` (webhook) ### Bitbucket * **Auth:** OAuth2 * **Composio Toolkit:** `bitbucket` * **API Docs:** [Bitbucket REST API](https://developer.atlassian.com/cloud/bitbucket/rest/intro/) * **PIE Status:** `new` * **Core Actions:** list\_repos, get\_repo, create\_repo, list\_pull\_requests, create\_pull\_request, merge\_pull\_request, list\_branches, list\_commits, list\_pipelines, trigger\_pipeline * **Auto Heartbeat Events:** * `push` (webhook: repo:push) * `pr_created` (webhook: pullrequest:created) * `pr_merged` (webhook: pullrequest:fulfilled) * `pipeline_completed` (webhook: repo:commit\_status\_updated) ### GitLab * **Auth:** OAuth2, API Key * **Composio Toolkit:** `gitlab` (likely in "Load More") * **API Docs:** [GitLab REST API](https://docs.gitlab.com/ee/api/rest/) * **PIE Status:** `new` * **Core Actions:** list\_projects, get\_project, list\_merge\_requests, create\_merge\_request, list\_issues, create\_issue, list\_pipelines, get\_pipeline, list\_branches, list\_commits * **Auto Heartbeat Events:** * `push` (webhook: Push Hook) * `mr_opened` (webhook: Merge Request Hook) * `mr_merged` (webhook: Merge Request Hook) * `issue_opened` (webhook: Issue Hook) * `pipeline_completed` (webhook: Pipeline Hook) ### DigitalOcean * **Auth:** Custom OAuth2 * **Composio Toolkit:** `digitalocean` (likely in "Load More") * **API Docs:** [DigitalOcean API](https://docs.digitalocean.com/reference/api/) * **PIE Status:** `exists` — `digitalocean.pie` * **Core Actions:** list\_droplets, create\_droplet, delete\_droplet, list\_databases, create\_database, list\_domains, create\_domain, list\_volumes, list\_load\_balancers, list\_kubernetes\_clusters * **Auto Heartbeat Events:** * `droplet_created` (tool: create\_droplet) * `droplet_destroyed` (tool: delete\_droplet) * `alert_triggered` (webhook) ### Additional DevOps Services (new, high-priority subset) | Service | Auth | API Docs | Core Actions | Key Heartbeats | |---------|------|----------|--------------|----------------| | **Cloudflare** | API Key | [Cloudflare API](https://developers.cloudflare.com/api/) | list\_zones, create\_dns\_record, list\_dns\_records, purge\_cache | `dns_changed`, `security_event` | | **Vercel** | API Key | [Vercel API](https://vercel.com/docs/rest-api) | list\_deployments, create\_deployment, list\_projects, list\_domains | `deployment_completed`, `deployment_failed` | | **Netlify** | OAuth2 | [Netlify API](https://docs.netlify.com/api/get-started/) | list\_sites, create\_deploy, list\_deploys, list\_forms | `deploy_succeeded`, `form_submission` | | **AWS** | API Key | [AWS SDK](https://docs.aws.amazon.com/) | s3\_list, s3\_upload, ec2\_list, lambda\_invoke, sqs\_send | `lambda_completed`, `s3_object_created` | | **CircleCI** | API Key | [CircleCI API](https://circleci.com/docs/api/v2/) | list\_pipelines, get\_pipeline, list\_workflows, get\_job | `pipeline_completed`, `job_failed` | | **Datadog** | API Key | [Datadog API](https://docs.datadoghq.com/api/) | list\_monitors, create\_monitor, query\_metrics, list\_events | `monitor_triggered`, `alert_fired` | | **PagerDuty** | API Key | [PagerDuty API](https://developer.pagerduty.com/api-reference/) | list\_incidents, create\_incident, acknowledge\_incident, resolve\_incident | `incident_triggered`, `incident_resolved` | | **Sentry** | API Key | [Sentry API](https://docs.sentry.io/api/) | list\_issues, get\_issue, list\_events, list\_projects | `issue_created`, `issue_resolved` | | **Docker Hub** | API Key | [Docker Hub API](https://docs.docker.com/docker-hub/api/) | list\_repos, list\_tags, get\_image\_details | `image_pushed`, `vulnerability_found` | | **Terraform Cloud** | API Key | [Terraform API](https://developer.hashicorp.com/terraform/cloud-docs/api-docs) | list\_workspaces, trigger\_run, get\_run, list\_variables | `run_completed`, `plan_failed` | | **Grafana** | API Key | [Grafana API](https://grafana.com/docs/grafana/latest/developers/http_api/) | list\_dashboards, get\_dashboard, list\_alerts, list\_datasources | `alert_triggered`, `alert_resolved` | | **New Relic** | API Key | [New Relic API](https://docs.newrelic.com/docs/apis/) | list\_applications, get\_app\_metrics, list\_alerts, query\_nrql | `alert_triggered`, `deployment_recorded` | | **Bugsnag** | API Key | [Bugsnag API](https://bugsnag.com/docs/api/) | list\_errors, get\_error, list\_events, list\_projects | `error_created`, `error_resolved` | | **Better Stack** | API Key | [Better Stack API](https://betterstack.com/docs/uptime/api/) | list\_monitors, create\_monitor, list\_incidents | `monitor_down`, `monitor_recovered` | | **Buildkite** | API Key | [Buildkite API](https://buildkite.com/docs/apis/rest-api) | list\_builds, create\_build, list\_pipelines, get\_build | `build_finished`, `build_failed` | | **Algolia** | API Key | [Algolia API](https://www.algolia.com/doc/rest-api/search/) | search, add\_records, update\_record, delete\_record, list\_indices | `record_added`, `index_updated` | | **Ably** | API Key | [Ably API](https://ably.com/docs/api/rest-api) | publish\_message, list\_channels, get\_presence, get\_stats | `message_published`, `presence_changed` | *** ## Category 5 — Document & File Management (76 services) ### Google Drive * **Auth:** OAuth2, Bearer Token * **Composio Toolkit:** `googledrive` * **API Docs:** [Google Drive API](https://developers.google.com/drive/api/reference/rest/v3) * **PIE Status:** `new` * **Core Actions:** list\_files, get\_file, upload\_file, create\_folder, move\_file, copy\_file, share\_file, delete\_file, search\_files, get\_permissions, create\_permission, export\_file, list\_changes * **Auto Heartbeat Events:** * `file_uploaded` (tool: upload\_file) * `file_shared` (tool: share\_file) * `file_modified` (webhook: changes) * `file_deleted` (tool: delete\_file) * `folder_created` (tool: create\_folder) ### Google Docs * **Auth:** OAuth2, Bearer Token * **Composio Toolkit:** `googledocs` * **API Docs:** [Google Docs API](https://developers.google.com/docs/api/reference/rest) * **PIE Status:** `new` * **Core Actions:** get\_document, create\_document, batch\_update, insert\_text, insert\_image, create\_table, update\_style, replace\_text * **Auto Heartbeat Events:** * `document_created` (tool: create\_document) * `document_updated` (tool: batch\_update) ### Dropbox * **Auth:** OAuth2 * **Composio Toolkit:** `dropbox` (likely in "Load More") * **API Docs:** [Dropbox API](https://www.dropbox.com/developers/documentation/http/documentation) * **PIE Status:** `exists` — `dropbox.pie` * **Core Actions:** list\_folder, upload\_file, download\_file, create\_folder, move\_file, copy\_file, delete\_file, search\_files, share\_file, get\_metadata, list\_shared\_links, create\_shared\_link * **Auto Heartbeat Events:** * `file_uploaded` (tool: upload\_file) * `file_shared` (tool: share\_file) * `file_deleted` (tool: delete\_file) * `file_modified` (webhook: list\_folder/longpoll) * **Gap:** Verify webhook/polling for file change detection. ### Box * **Auth:** OAuth2 * **Composio Toolkit:** `box` * **API Docs:** [Box API](https://developer.box.com/reference/) * **PIE Status:** `new` * **Core Actions:** list\_files, upload\_file, download\_file, create\_folder, move\_file, copy\_file, delete\_file, share\_file, search, list\_collaborations, add\_collaboration, get\_metadata, set\_metadata * **Auto Heartbeat Events:** * `file_uploaded` (webhook: FILE.UPLOADED) * `file_downloaded` (webhook: FILE.DOWNLOADED) * `file_shared` (tool: share\_file) * `comment_added` (webhook: COMMENT.CREATED) * `collaboration_added` (webhook: COLLABORATION.CREATED) ### Additional Document Services (new) | Service | Auth | API Docs | Core Actions | Key Heartbeats | |---------|------|----------|--------------|----------------| | **DocuSign** | OAuth2 | [DocuSign API](https://developers.docusign.com/docs/esign-rest-api/) | send\_envelope, get\_envelope, list\_envelopes, get\_document | `envelope_signed`, `envelope_completed`, `envelope_declined` | | **OneDrive** | OAuth2 | [OneDrive API](https://learn.microsoft.com/en-us/onedrive/developer/) | list\_files, upload\_file, download\_file, create\_folder, share | `file_uploaded`, `file_shared`, `file_modified` | | **SharePoint** | OAuth2 | [SharePoint API](https://learn.microsoft.com/en-us/sharepoint/dev/) | list\_sites, list\_files, upload\_file, create\_list, add\_list\_item | `file_uploaded`, `list_item_created` | | **Cloudinary** | API Key | [Cloudinary API](https://cloudinary.com/documentation/admin_api) | upload\_image, transform\_image, list\_resources, delete\_resource | `image_uploaded`, `transformation_complete` | | **BoldSign** | OAuth2 | [BoldSign API](https://www.boldsign.com/api/) | create\_document, send\_for\_signature, get\_document | `document_signed`, `document_completed` | | **Documenso** | API Key | [Documenso API](https://documen.so/api) | create\_document, send\_document, get\_document | `document_signed`, `document_completed` | | **CloudConvert** | API Key | [CloudConvert API](https://cloudconvert.com/api/v2) | create\_job, convert\_file, list\_jobs | `conversion_completed` | *** ## Category 6 — Finance & Accounting (51 services) ### Stripe (promote from server service) * **Auth:** API Key, OAuth2 * **Composio Toolkit:** `stripe` * **API Docs:** [Stripe API](https://stripe.com/docs/api) * **PIE Status:** `needs-update` — exists as server service, needs full connector * **Core Actions:** list\_customers, create\_customer, update\_customer, list\_charges, create\_charge, list\_subscriptions, create\_subscription, cancel\_subscription, list\_invoices, create\_invoice, send\_invoice, list\_payment\_intents, create\_payment\_intent, list\_products, create\_product, list\_prices, create\_price, create\_refund, list\_balance\_transactions, create\_checkout\_session * **Auto Heartbeat Events:** * `payment_succeeded` (webhook: payment\_intent.succeeded) * `payment_failed` (webhook: payment\_intent.payment\_failed) * `subscription_created` (webhook: customer.subscription.created) * `subscription_cancelled` (webhook: customer.subscription.deleted) * `invoice_paid` (webhook: invoice.paid) * `invoice_payment_failed` (webhook: invoice.payment\_failed) * `charge_refunded` (webhook: charge.refunded) * `customer_created` (webhook: customer.created) * `checkout_completed` (webhook: checkout.session.completed) ### QuickBooks * **Auth:** OAuth2 * **Composio Toolkit:** `quickbooks` (likely in "Load More") * **API Docs:** [QuickBooks API](https://developer.intuit.com/app/developer/qbo/docs/api/accounting/all-entities/account) * **PIE Status:** `new` * **Core Actions:** list\_invoices, create\_invoice, send\_invoice, list\_customers, create\_customer, list\_payments, record\_payment, list\_expenses, create\_expense, get\_profit\_loss\_report, get\_balance\_sheet, list\_items, create\_item * **Auto Heartbeat Events:** * `invoice_created` (webhook: Invoice.Create) * `payment_received` (webhook: Payment.Create) * `customer_created` (webhook: Customer.Create) * `expense_created` (tool: create\_expense) ### Additional Finance Services (new) | Service | Auth | API Docs | Core Actions | Key Heartbeats | |---------|------|----------|--------------|----------------| | **FreshBooks** | OAuth2 | [FreshBooks API](https://www.freshbooks.com/api/start) | list\_invoices, create\_invoice, list\_clients, list\_expenses | `invoice_created`, `payment_received` | | **Xero** | OAuth2 | [Xero API](https://developer.xero.com/documentation/api/accounting/overview) | list\_invoices, create\_invoice, list\_contacts, list\_payments | `invoice_created`, `payment_received` | | **Brex** | OAuth2, API Key | [Brex API](https://developer.brex.com/) | list\_transactions, list\_cards, list\_users | `transaction_created`, `card_activated` | | **Coinbase** | API Key | [Coinbase API](https://docs.cdp.coinbase.com/) | list\_accounts, get\_balance, send\_crypto, list\_transactions | `transaction_completed`, `price_alert` | | **Plaid** | API Key | [Plaid API](https://plaid.com/docs/api/) | list\_accounts, get\_transactions, get\_balance, get\_identity | `transaction_posted`, `balance_updated` | | **PayPal** | OAuth2 | [PayPal API](https://developer.paypal.com/docs/api/overview/) | create\_payment, list\_payments, create\_invoice, send\_payout | `payment_received`, `invoice_paid`, `dispute_created` | | **Square** | OAuth2 | [Square API](https://developer.squareup.com/reference/square) | list\_payments, create\_payment, list\_orders, list\_customers | `payment_completed`, `order_created` | | **FreeAgent** | OAuth2 | [FreeAgent API](https://dev.freeagent.com/docs/) | list\_invoices, create\_invoice, list\_contacts, list\_expenses | `invoice_sent`, `payment_received` | | **Alpha Vantage** | API Key | [Alpha Vantage API](https://www.alphavantage.co/documentation/) | get\_stock\_quote, get\_time\_series, get\_forex\_rate | `price_threshold_crossed` | | **Flutterwave** | API Key | [Flutterwave API](https://developer.flutterwave.com/reference) | create\_payment, verify\_payment, list\_transactions | `payment_successful`, `transfer_completed` | | **Lemon Squeezy** | API Key | [Lemon Squeezy API](https://docs.lemonsqueezy.com/api) | list\_orders, list\_subscriptions, list\_products | `order_created`, `subscription_created` | | **Moneybird** | OAuth2, API Key | [Moneybird API](https://developer.moneybird.com/) | list\_invoices, create\_invoice, list\_contacts | `invoice_sent`, `payment_received` | *** ## Category 7 — Marketing & Social Media (96 services) ### LinkedIn (promote from tool plugin) * **Auth:** OAuth2 * **Composio Toolkit:** `linkedin` * **API Docs:** [LinkedIn API](https://learn.microsoft.com/en-us/linkedin/shared/authentication/) * **PIE Status:** `needs-update` — `linkedin-engagement` is tool-tier, needs full connector * **Core Actions:** get\_profile, list\_connections, create\_post, get\_post, list\_posts, create\_comment, list\_comments, share\_article, get\_company\_page, list\_followers, get\_analytics * **Auto Heartbeat Events:** * `post_published` (tool: create\_post) * `comment_received` (webhook) * `connection_accepted` (webhook) * `mention_received` (webhook) ### Reddit (promote from tool plugin) * **Auth:** OAuth2 * **Composio Toolkit:** `reddit` * **API Docs:** [Reddit API](https://www.reddit.com/dev/api/) * **PIE Status:** `needs-update` — `reddit-researcher` is tool-tier, needs full connector * **Core Actions:** list\_posts, create\_post, get\_post, list\_comments, create\_comment, upvote, downvote, list\_subreddits, subscribe, get\_user\_info, search, list\_messages, send\_message * **Auto Heartbeat Events:** * `post_published` (tool: create\_post) * `comment_received` (webhook) * `mention_received` (webhook) * `dm_received` (webhook) ### Facebook (promote from tool plugin) * **Auth:** OAuth2 * **Composio Toolkit:** `facebook` * **API Docs:** [Facebook Graph API](https://developers.facebook.com/docs/graph-api/) * **PIE Status:** `needs-update` — needs full connector * **Core Actions:** get\_page, list\_posts, create\_post, get\_post\_insights, list\_comments, reply\_to\_comment, upload\_photo, upload\_video, list\_events, create\_event, get\_page\_insights, list\_messages * **Auto Heartbeat Events:** * `post_published` (tool: create\_post) * `comment_received` (webhook: feed) * `message_received` (webhook: messages) * `page_mentioned` (webhook: mention) ### Additional Marketing/Social Services (new, high-priority subset) | Service | Auth | API Docs | Core Actions | Key Heartbeats | |---------|------|----------|--------------|----------------| | **Mailchimp** | OAuth2 | [Mailchimp API](https://mailchimp.com/developer/marketing/api/) | list\_campaigns, create\_campaign, send\_campaign, list\_members, add\_member | `campaign_sent`, `member_subscribed`, `email_opened` | | **SendGrid** | API Key | [SendGrid API](https://docs.sendgrid.com/api-reference) | send\_email, list\_contacts, add\_contact, list\_campaigns | `email_delivered`, `email_bounced`, `email_opened` | | **Brevo (Sendinblue)** | API Key, OAuth2 | [Brevo API](https://developers.brevo.com/) | send\_email, send\_sms, list\_contacts, create\_campaign | `email_delivered`, `email_opened`, `contact_created` | | **ActiveCampaign** | API Key | [ActiveCampaign API](https://developers.activecampaign.com/reference) | list\_contacts, create\_contact, create\_deal, list\_automations | `contact_created`, `deal_updated`, `automation_completed` | | **Constant Contact** | OAuth2 | [Constant Contact API](https://developer.constantcontact.com/api_guide/) | create\_campaign, list\_contacts, add\_contact | `campaign_sent`, `contact_subscribed` | | **ConvertKit** | API Key | [ConvertKit API](https://developers.convertkit.com/) | list\_subscribers, add\_subscriber, list\_sequences, add\_to\_sequence | `subscriber_added`, `sequence_completed` | | **Drip** | OAuth2 | [Drip API](https://developer.drip.com/) | list\_subscribers, create\_subscriber, list\_campaigns | `subscriber_created`, `campaign_completed` | | **Buffer** | OAuth2 | [Buffer API](https://buffer.com/developers/api) | create\_post, list\_profiles, list\_updates, get\_analytics | `post_published`, `post_scheduled` | | **Hootsuite** | OAuth2 | [Hootsuite API](https://developer.hootsuite.com/) | schedule\_post, list\_social\_profiles, get\_analytics | `post_published`, `engagement_received` | | **Customer.io** | API Key | [Customer.io API](https://customer.io/docs/api/) | identify\_person, track\_event, send\_email, list\_campaigns | `email_opened`, `event_tracked` | | **Klaviyo** | API Key | [Klaviyo API](https://developers.klaviyo.com/en) | list\_profiles, create\_profile, list\_flows, list\_campaigns | `profile_created`, `campaign_sent`, `flow_triggered` | | **Semrush** | API Key | [Semrush API](https://developer.semrush.com/) | get\_domain\_overview, get\_keywords, get\_backlinks | `rank_changed`, `new_backlink` | *** ## Category 8 — Sales & Customer Support (67 services) | Service | Auth | API Docs | Core Actions | Key Heartbeats | |---------|------|----------|--------------|----------------| | **Zendesk** | OAuth2 | [Zendesk API](https://developer.zendesk.com/api-reference/) | list\_tickets, create\_ticket, update\_ticket, list\_users, add\_comment, search | `ticket_created`, `ticket_updated`, `ticket_solved`, `comment_added` | | **Freshdesk** | Basic | [Freshdesk API](https://developers.freshdesk.com/api/) | list\_tickets, create\_ticket, update\_ticket, reply\_ticket, list\_contacts | `ticket_created`, `ticket_resolved`, `reply_received` | | **Intercom** | OAuth2 | [Intercom API](https://developers.intercom.com/) | list\_conversations, reply\_conversation, create\_contact, list\_contacts | `conversation_created`, `message_received`, `conversation_closed` | | **Helpdesk** | API Key | [HelpDesk API](https://api.helpdesk.com/docs) | list\_tickets, create\_ticket, update\_ticket, assign\_ticket | `ticket_created`, `ticket_assigned` | | **Gorgias** | OAuth2 | [Gorgias API](https://developers.gorgias.com/) | list\_tickets, create\_ticket, reply\_ticket, list\_customers | `ticket_created`, `ticket_closed`, `customer_replied` | | **Drift** | OAuth2 | [Drift API](https://devdocs.drift.com/) | list\_conversations, send\_message, list\_contacts | `conversation_started`, `message_received` | | **Crisp** | API Key | [Crisp API](https://docs.crisp.chat/references/rest-api/v1/) | send\_message, list\_conversations, get\_conversation | `message_received`, `conversation_started` | | **LiveChat** | API Key | [LiveChat API](https://developers.livechat.com/) | send\_message, list\_chats, list\_agents, list\_archives | `chat_started`, `message_received`, `chat_ended` | | **Tidio** | API Key | [Tidio API](https://www.tidio.com/api/) | send\_message, list\_conversations, list\_visitors | `conversation_started`, `message_received` | | **Autobound** | API Key | [Autobound API](https://docs.autobound.ai/) | generate\_outreach, get\_insights, list\_prospects | `outreach_generated` | | **Findymail** | API Key | [Findymail API](https://docs.findymail.com/) | find\_email, verify\_email, enrich\_contact | `email_found`, `email_verified` | *** ## Category 9 — E-commerce (34 services) | Service | Auth | API Docs | Core Actions | Key Heartbeats | |---------|------|----------|--------------|----------------| | **Shopify** | API Key, OAuth2 | [Shopify Admin API](https://shopify.dev/docs/api/admin-rest) | list\_products, create\_product, update\_product, list\_orders, get\_order, create\_order, list\_customers, update\_inventory, list\_collections | `order_created`, `order_fulfilled`, `product_updated`, `customer_created` | | **WooCommerce** | API Key | [WooCommerce API](https://woocommerce.github.io/woocommerce-rest-api-docs/) | list\_products, create\_product, list\_orders, update\_order, list\_customers | `order_created`, `order_completed`, `product_created` | | **BigCommerce** | API Key | [BigCommerce API](https://developer.bigcommerce.com/docs/rest-management) | list\_products, create\_product, list\_orders, list\_customers | `order_created`, `order_shipped` | | **Gumroad** | OAuth2 | [Gumroad API](https://app.gumroad.com/api) | list\_products, create\_product, list\_sales, get\_sale | `sale_completed`, `product_created` | | **Lemon Squeezy** | API Key | [Lemon Squeezy API](https://docs.lemonsqueezy.com/api) | list\_products, list\_orders, list\_subscriptions, create\_checkout | `order_created`, `subscription_created` | | **ShipEngine** | API Key | [ShipEngine API](https://shipengine.github.io/shipengine-openapi/) | create\_label, track\_shipment, get\_rates, validate\_address | `shipment_delivered`, `tracking_updated` | | **Shippo** | API Key, OAuth2 | [Shippo API](https://docs.goshippo.com/) | create\_shipment, get\_rates, create\_label, track\_package | `shipment_created`, `label_created` | | **BaseLinker** | API Key | [BaseLinker API](https://api.baselinker.com/) | list\_orders, get\_order, add\_order, update\_order | `order_received`, `order_shipped` | *** ## Category 10 — HR & Recruiting (12 services) | Service | Auth | API Docs | Core Actions | Key Heartbeats | |---------|------|----------|--------------|----------------| | **Ashby** | API Key | [Ashby API](https://developers.ashbyhq.com/) | list\_candidates, create\_candidate, list\_jobs, update\_candidate\_stage | `application_received`, `stage_changed`, `offer_sent` | | **BambooHR** | OAuth2, API Key | [BambooHR API](https://documentation.bamboohr.com/reference) | list\_employees, get\_employee, create\_employee, list\_time\_off | `employee_created`, `time_off_requested`, `time_off_approved` | | **Lever** | API Key, OAuth2 | [Lever API](https://hire.lever.co/developer/documentation) | list\_candidates, create\_candidate, list\_postings, advance\_candidate | `candidate_added`, `candidate_advanced`, `offer_sent` | | **Workable** | API Key | [Workable API](https://workable.readme.io/) | list\_candidates, create\_candidate, list\_jobs, move\_candidate | `candidate_applied`, `candidate_moved` | | **Workday** | OAuth2 | [Workday API](https://community.workday.com/sites/default/files/file-hosting/restapi/) | get\_worker, list\_workers, get\_time\_off, get\_payslip | `employee_hired`, `time_off_approved` | | **SAP SuccessFactors** | SAML | [SAP SF API](https://help.sap.com/docs/SAP_SUCCESSFACTORS/) | list\_employees, get\_employee, list\_learning\_activities | `employee_onboarded`, `course_completed` | | **Recruitee** | API Key, OAuth2 | [Recruitee API](https://docs.recruitee.com/reference) | list\_candidates, create\_candidate, list\_offers | `candidate_applied`, `candidate_hired` | | **Connecteam** | API Key | [Connecteam API](https://developer.connecteam.com/) | list\_users, list\_shifts, clock\_in, clock\_out | `shift_completed`, `clock_in_recorded` | *** ## Category 11 — Scheduling & Booking (19 services) ### Google Calendar * **Auth:** OAuth2, Bearer Token * **Composio Toolkit:** `googlecalendar` * **API Docs:** [Google Calendar API](https://developers.google.com/calendar/api/v3/reference) * **PIE Status:** `exists` — `google-calendar.pie` * **Core Actions:** list\_events, create\_event, update\_event, delete\_event, get\_event, list\_calendars, create\_calendar, get\_free\_busy, watch\_events, quick\_add\_event * **Auto Heartbeat Events:** * `event_created` (tool: create\_event) * `event_updated` (tool: update\_event) * `event_cancelled` (tool: delete\_event) * `event_starting_soon` (polling-based) * `attendee_responded` (webhook: changes) * **Gap:** Verify webhook/push notification support. ### Additional Scheduling Services (new) | Service | Auth | API Docs | Core Actions | Key Heartbeats | |---------|------|----------|--------------|----------------| | **Calendly** | OAuth2 | [Calendly API](https://developer.calendly.com/api-docs) | list\_events, get\_event, list\_event\_types, list\_invitees | `event_scheduled`, `event_cancelled`, `invitee_created` | | **Cal.com** | API Key | [Cal.com API](https://cal.com/docs/api-reference) | list\_bookings, create\_booking, list\_event\_types, list\_availability | `booking_created`, `booking_cancelled`, `booking_rescheduled` | | **Acuity Scheduling** | OAuth2 | [Acuity API](https://developers.acuityscheduling.com/reference) | list\_appointments, create\_appointment, list\_availability | `appointment_scheduled`, `appointment_cancelled` | | **Doodle** | OAuth2 | [Doodle API](https://doodle.com/api/) | create\_poll, list\_polls, get\_poll\_results | `poll_closed`, `participant_voted` | *** ## Category 12 — AI & Machine Learning (79 services) | Service | Auth | API Docs | Core Actions | Key Heartbeats | |---------|------|----------|--------------|----------------| | **OpenAI** | API Key | [OpenAI API](https://platform.openai.com/docs/api-reference) | create\_completion, create\_embedding, create\_image, list\_models | `completion_finished` | | **Anthropic** | API Key | [Anthropic API](https://docs.anthropic.com/en/api/) | create\_message, list\_models | `message_completed` | | **Deepgram** | API Key | [Deepgram API](https://developers.deepgram.com/) | transcribe\_audio, transcribe\_url, list\_projects | `transcription_completed` | | **ElevenLabs** | API Key | [ElevenLabs API](https://docs.elevenlabs.io/api-reference) | text\_to\_speech, list\_voices, clone\_voice | `audio_generated` | | **Replicate** | API Key | [Replicate API](https://replicate.com/docs/reference/http) | create\_prediction, get\_prediction, list\_models | `prediction_completed` | | **Hugging Face** | API Key | [Hugging Face API](https://huggingface.co/docs/api-inference/) | run\_inference, list\_models, get\_model | `inference_completed` | | **Stability AI** | API Key | [Stability API](https://platform.stability.ai/docs/api-reference) | generate\_image, upscale\_image, edit\_image | `image_generated` | | **DataRobot** | API Key | [DataRobot API](https://docs.datarobot.com/en/docs/api/) | create\_project, train\_model, make\_prediction | `model_trained`, `prediction_completed` | | **Perplexity AI** | API Key | [Perplexity API](https://docs.perplexity.ai/) | create\_completion, search | `search_completed` | | **Gemini** | API Key | [Gemini API](https://ai.google.dev/api/) | generate\_content, embed\_content, list\_models | `content_generated` | | **DeepSeek** | API Key | [DeepSeek API](https://platform.deepseek.com/api-docs) | create\_chat\_completion, list\_models | `completion_finished` | | **Fal.ai** | API Key | [Fal.ai API](https://fal.ai/docs) | run\_model, list\_models, queue\_request | `generation_completed` | *** ## Category 13 — Analytics & Data (48 services) | Service | Auth | API Docs | Core Actions | Key Heartbeats | |---------|------|----------|--------------|----------------| | **Google Analytics** | OAuth2 | [GA4 API](https://developers.google.com/analytics/devguides/reporting/data/v1) | run\_report, get\_realtime\_report, list\_properties | `report_generated` | | **Mixpanel** | API Key | [Mixpanel API](https://developer.mixpanel.com/reference) | track\_event, get\_funnels, get\_retention, query\_jql | `event_tracked`, `funnel_completed` | | **Amplitude** | Basic | [Amplitude API](https://www.docs.developers.amplitude.com/) | track\_event, get\_user\_activity, get\_cohorts | `event_tracked` | | **PostHog** | API Key | [PostHog API](https://posthog.com/docs/api) | capture\_event, list\_persons, get\_insights, list\_feature\_flags | `event_captured`, `feature_flag_changed` | | **Snowflake** | OAuth2 | [Snowflake SQL API](https://docs.snowflake.com/en/developer-guide/sql-api/) | execute\_query, list\_databases, list\_schemas, list\_tables | `query_completed` | | **Databricks** | OAuth2, API Key | [Databricks API](https://docs.databricks.com/api/) | run\_query, list\_clusters, create\_job, run\_job | `job_completed`, `query_finished` | | **BigQuery** | Service Account | [BigQuery API](https://cloud.google.com/bigquery/docs/reference/rest) | run\_query, list\_datasets, list\_tables, create\_table | `query_completed`, `job_finished` | | **Elasticsearch** | API Key, Basic | [Elasticsearch API](https://www.elastic.co/guide/en/elasticsearch/reference/current/rest-apis.html) | search, index\_document, get\_document, create\_index | `document_indexed`, `alert_triggered` | | **SerpApi** | API Key | [SerpApi](https://serpapi.com/search-api) | search\_google, search\_bing, search\_youtube | `search_completed` | | **Firecrawl** | API Key | [Firecrawl API](https://docs.firecrawl.dev/) | scrape\_url, crawl\_site, search | `crawl_completed`, `page_scraped` | | **Segment** | API Key | [Segment API](https://segment.com/docs/connections/sources/catalog/) | identify, track, page, group | `event_tracked`, `user_identified` | *** ## Category 14 — Design & Creative Tools (31 services) | Service | Auth | API Docs | Core Actions | Key Heartbeats | |---------|------|----------|--------------|----------------| | **Figma** | OAuth2, API Key | [Figma API](https://www.figma.com/developers/api) | get\_file, list\_projects, list\_files, get\_comments, post\_comment, get\_images | `comment_added`, `file_updated` | | **Canva** | OAuth2 | [Canva API](https://www.canva.dev/docs/connect/) | create\_design, list\_designs, export\_design, list\_templates | `design_created`, `design_exported` | | **Miro** | OAuth2 | [Miro API](https://developers.miro.com/reference/api-reference) | list\_boards, create\_board, create\_sticky, list\_items, create\_frame | `board_updated`, `item_created` | | **Webflow** | OAuth2, API Key | [Webflow API](https://developers.webflow.com/) | list\_sites, list\_collections, list\_items, create\_item, publish\_site | `site_published`, `item_created`, `form_submitted` | | **Cloudinary** | API Key | [Cloudinary API](https://cloudinary.com/documentation/) | upload, transform, search, delete, list\_resources | `upload_completed`, `transformation_done` | | **Pexels** | API Key | [Pexels API](https://www.pexels.com/api/documentation/) | search\_photos, search\_videos, get\_photo | — (read-only) | | **Unsplash** | API Key | [Unsplash API](https://unsplash.com/documentation) | search\_photos, get\_photo, list\_collections | — (read-only) | *** ## Category 15 — Education & LMS (14 services) | Service | Auth | API Docs | Core Actions | Key Heartbeats | |---------|------|----------|--------------|----------------| | **Google Classroom** | OAuth2 | [Classroom API](https://developers.google.com/classroom/reference/rest) | list\_courses, create\_course, list\_students, create\_assignment, list\_submissions | `assignment_submitted`, `grade_posted` | | **Canvas LMS** | OAuth2, API Key | [Canvas API](https://canvas.instructure.com/doc/api/) | list\_courses, create\_assignment, list\_students, grade\_submission | `submission_received`, `grade_posted` | | **Blackboard** | OAuth2 | [Blackboard API](https://developer.anthology.com/portal/displayApi) | list\_courses, create\_course, list\_users, list\_gradebook | `assignment_submitted`, `grade_updated` | | **D2L Brightspace** | OAuth2 | [Brightspace API](https://docs.valence.desire2learn.com/) | list\_courses, list\_users, get\_grades, create\_assignment | `grade_posted`, `assignment_submitted` | *** ## Category 16 — Entertainment & Media (~30 services) ### Spotify * **Auth:** OAuth2 * **Composio Toolkit:** `spotify` * **API Docs:** [Spotify Web API](https://developer.spotify.com/documentation/web-api) * **PIE Status:** `exists` — `spotify.pie` * **Core Actions:** search, get\_track, get\_album, get\_artist, get\_playlist, create\_playlist, add\_tracks\_to\_playlist, get\_recommendations, get\_currently\_playing, play, pause, skip, get\_user\_top\_items, get\_saved\_tracks * **Auto Heartbeat Events:** * `track_played` (tool: play) * `playlist_created` (tool: create\_playlist) * `track_added_to_playlist` (tool: add\_tracks\_to\_playlist) * **Gap:** Verify action coverage. ### Additional Entertainment Services (new) | Service | Auth | API Docs | Core Actions | Key Heartbeats | |---------|------|----------|--------------|----------------| | **YouTube** | OAuth2 | [YouTube Data API](https://developers.google.com/youtube/v3/docs) | list\_videos, search, get\_channel, list\_playlists, upload\_video, list\_comments | `video_uploaded`, `comment_received`, `subscriber_gained` | | **Twitch** | OAuth2 | [Twitch API](https://dev.twitch.tv/docs/api/) | get\_streams, get\_users, list\_followers, create\_clip | `stream_online`, `stream_offline`, `new_follower` | | **SoundCloud** | OAuth2 | [SoundCloud API](https://developers.soundcloud.com/docs/api/reference) | list\_tracks, upload\_track, get\_user, list\_playlists | `track_uploaded`, `track_liked` | | **Vimeo** | OAuth2 | [Vimeo API](https://developer.vimeo.com/api/reference) | list\_videos, upload\_video, get\_video, list\_folders | `video_uploaded`, `video_finished_processing` | | **Podcast Index** | API Key | [Podcast Index API](https://podcastindex-org.github.io/docs-api/) | search\_podcasts, get\_episodes, get\_trending | `new_episode_detected` | *** ## Category 17 — Advertising & Marketing (16 services) | Service | Auth | API Docs | Core Actions | Key Heartbeats | |---------|------|----------|--------------|----------------| | **Google Ads** | OAuth2 | [Google Ads API](https://developers.google.com/google-ads/api/docs/start) | list\_campaigns, create\_campaign, list\_ad\_groups, get\_report, list\_keywords | `campaign_created`, `budget_exceeded`, `conversion_recorded` | | **Meta Ads** | OAuth2, API Key | [Meta Marketing API](https://developers.facebook.com/docs/marketing-apis/) | list\_campaigns, create\_campaign, list\_ad\_sets, create\_ad, get\_insights | `campaign_created`, `ad_approved`, `budget_spent` | | **TikTok Ads** | OAuth2 | [TikTok Marketing API](https://business-api.tiktok.com/portal/docs) | list\_campaigns, create\_campaign, get\_report | `campaign_created`, `ad_approved` | | **LinkedIn Ads** | OAuth2 | [LinkedIn Ads API](https://learn.microsoft.com/en-us/linkedin/marketing/) | list\_campaigns, create\_campaign, get\_analytics | `campaign_created`, `lead_generated` | | **Semrush** | API Key | [Semrush API](https://developer.semrush.com/) | get\_domain\_analytics, get\_keyword\_data, get\_backlinks | `rank_changed` | | **Tapfiliate** | API Key | [Tapfiliate API](https://tapfiliate.com/docs/rest/) | list\_affiliates, list\_conversions, create\_affiliate | `conversion_recorded`, `affiliate_signed_up` | *** ## Category 18 — Workflow Automation (35 services) | Service | Auth | API Docs | Core Actions | Key Heartbeats | |---------|------|----------|--------------|----------------| | **Make (Integromat)** | API Key | [Make API](https://www.make.com/en/api-documentation) | list\_scenarios, run\_scenario, list\_connections | `scenario_completed`, `scenario_failed` | | **Zapier** | OAuth2 | [Zapier API](https://platform.zapier.com/docs/api) | list\_zaps, trigger\_zap | `zap_triggered`, `zap_completed` | | **n8n** | API Key | [n8n API](https://docs.n8n.io/api/) | list\_workflows, execute\_workflow, list\_executions | `workflow_completed`, `workflow_failed` | | **Process Street** | API Key | [Process Street API](https://developer.process.st/) | list\_checklists, create\_checklist, complete\_task | `checklist_completed`, `task_completed` | | **Crowdin** | API Key, OAuth2 | [Crowdin API](https://developer.crowdin.com/api/v2/) | list\_projects, upload\_file, list\_translations | `translation_completed`, `file_uploaded` | *** ## Category 19 — Social Media (~25 services) ### Instagram (promote from tool plugin) * **Auth:** OAuth2 * **Composio Toolkit:** `instagram` (likely in "Load More") * **API Docs:** [Instagram Graph API](https://developers.facebook.com/docs/instagram-api/) * **PIE Status:** `needs-update` — `instagram-researcher` is tool-tier * **Core Actions:** list\_media, create\_post, get\_insights, list\_comments, reply\_to\_comment, get\_stories, get\_profile, list\_hashtag\_media * **Auto Heartbeat Events:** * `post_published` (tool: create\_post) * `comment_received` (webhook: comments) * `mention_received` (webhook: mentions) * `story_mention` (webhook: story\_mentions) ### TikTok (promote from tool plugin) * **Auth:** OAuth2 * **Composio Toolkit:** `tiktok` (likely in "Load More") * **API Docs:** [TikTok API](https://developers.tiktok.com/doc/overview/) * **PIE Status:** `needs-update` — `tiktok-researcher` is tool-tier * **Core Actions:** list\_videos, upload\_video, get\_video\_insights, get\_user\_info, list\_comments * **Auto Heartbeat Events:** * `video_published` (tool: upload\_video) * `comment_received` (webhook) * `video_trending` (custom) ### Additional Social Media Services (new) | Service | Auth | API Docs | Core Actions | Key Heartbeats | |---------|------|----------|--------------|----------------| | **Pinterest** | OAuth2 | [Pinterest API](https://developers.pinterest.com/docs/api/v5/) | list\_pins, create\_pin, list\_boards, create\_board, get\_analytics | `pin_created`, `pin_saved` | | **Threads** | OAuth2 | [Threads API](https://developers.facebook.com/docs/threads/) | create\_post, list\_posts, get\_insights, reply\_to\_post | `post_published`, `reply_received` | | **Mastodon** | OAuth2 | [Mastodon API](https://docs.joinmastodon.org/api/) | create\_status, list\_timeline, list\_notifications, list\_followers | `status_posted`, `mention_received`, `follower_gained` | | **Bluesky** | API Key | [Bluesky AT Protocol](https://docs.bsky.app/) | create\_post, list\_feed, list\_notifications, get\_profile | `post_created`, `mention_received` | *** ## Category 20 — Data & Analytics (~20 services) | Service | Auth | API Docs | Core Actions | Key Heartbeats | |---------|------|----------|--------------|----------------| | **Clearbit** | API Key | [Clearbit API](https://dashboard.clearbit.com/docs) | enrich\_person, enrich\_company, reveal\_visitor | `person_enriched`, `company_enriched` | | **PeopleDataLabs** | API Key | [PDL API](https://docs.peopledatalabs.com/) | enrich\_person, enrich\_company, search\_people | `person_enriched` | | **ZoomInfo** | API Key | [ZoomInfo API](https://api-docs.zoominfo.com/) | search\_contacts, enrich\_contact, search\_companies | `contact_found`, `company_enriched` | | **Crunchbase** | API Key | [Crunchbase API](https://data.crunchbase.com/docs) | search\_organizations, get\_organization, search\_people | `funding_round_detected` | *** ## Priority Tiers ### Tier 1 — Must Have (high-demand, existing overlap to deepen, or massive user base) 1. **Outlook** — #2 email globally, pairs with Gmail 2. **Microsoft Teams** — enterprise messaging, pairs with Slack 3. **Google Sheets** — most-requested data connector 4. **Google Drive** — file management baseline 5. **Google Docs** — document creation 6. **Jira** — #1 project management for dev teams 7. **Salesforce** — #1 CRM 8. **Stripe** — already have server service, promote to connector 9. **Airtable** — popular no-code database 10. **Linear** — fast-growing dev PM tool 11. **Calendly** — #1 scheduling tool 12. **LinkedIn** — promote from tool to full connector 13. **Shopify** — #1 e-commerce platform 14. **Zendesk** — #1 customer support 15. **Discord** — massive community platform ### Tier 2 — Should Have (strong demand, clear use cases) 1. ClickUp, Monday.com, Basecamp (PM tools) 2. Pipedrive, Freshsales, Zoho CRM (CRM) 3. Confluence, Box, OneDrive, SharePoint (docs/files) 4. Intercom, Freshdesk, Gorgias (support) 5. Telegram, Twilio (promote from server services) 6. Mailchimp, SendGrid, Brevo (email marketing) 7. Reddit, Facebook, Instagram, YouTube, TikTok (social — promote from tools) 8. QuickBooks, FreshBooks, Xero (accounting) 9. Google Ads, Google Analytics (advertising/analytics) 10. Supabase, Bitbucket, GitLab (dev tools) 11. Cal.com, Calendly (scheduling) 12. Figma, Canva, Miro (design) 13. BambooHR, Lever, Ashby (HR) ### Tier 3 — Nice to Have (niche or lower demand) All remaining services from each category. *** ## Implementation Notes ### Connector Architecture Pattern Every PIE connector follows this structure: ```yaml --- name: service-name displayName: Service Name description: Description of what the connector does tier: connector version: 1.0.0 manifest: trigger: auto oauth: provider: custom # or google, github, slack, notion providerName: Service Name authorizationUrl: https://service.com/oauth/authorize tokenUrl: https://service.com/oauth/token userInfoUrl: https://service.com/api/me # optional scopes: - scope1 - scope2 clientIdSecret: SERVICE_CLIENT_ID clientSecretSecret: SERVICE_CLIENT_SECRET developerSecrets: SERVICE_CLIENT_ID: description: OAuth Client ID required: true SERVICE_CLIENT_SECRET: description: OAuth Client Secret required: true automation: triggers: - type: webhook eventTypeField: event_type events: - name: event_name description: When something happens - type: interval everyMs: 60000 description: Polling for changes allowManualRun: true timeout: 120 heartbeatEvents: events: - id: event_id displayName: Event Display Name description: When this event fires matchers: - source: webhook eventName: event_name - source: tool action: action_name widget: launchable: true maxHeight: 620 ``` ### Heartbeat Auto-Generation Strategy For every connector, heartbeat events should be automatically generated from: 1. **Webhook events** — When you declare `automation.triggers[type=webhook].events`, those webhook event names automatically become subscribable heartbeat events via `buildPluginHeartbeatCatalog()` in the platform. Connector authors get these for free. 2. **Tool action completions** — Explicitly declare heartbeat events with `source: tool` matchers for important write operations (create, update, delete, send). 3. **Widget interactions** — Declare `source: widget` matchers for user-initiated actions in the connector's widget UI. 4. **AI Suggestions** — The `plugin-heartbeat-suggestion-service` can also analyze connector code and suggest additional heartbeat events automatically via LLM analysis. ### Standard Action Categories Per Connector Every connector should aim to support these action categories where the service API allows: | Category | Actions | |----------|---------| | **List/Read** | `list_{entities}`, `get_{entity}`, `search_{entities}` | | **Create** | `create_{entity}` | | **Update** | `update_{entity}`, `modify_{entity}` | | **Delete** | `delete_{entity}`, `archive_{entity}` | | **Specialized** | Service-specific operations (e.g., `send_email`, `merge_pull_request`) | ### OAuth Provider Notes * **Built-in providers:** `google`, `github`, `slack`, `notion` — these have pre-built OAuth flows * **Custom providers:** Use `provider: custom` with `authorizationUrl`, `tokenUrl`, etc. * **API Key services:** Use `developerSecrets` with API key fields (no OAuth block) *** *Total Composio services cataloged: ~982 across 20 categories* *Existing PIE connectors: 14 (+ 3 server services + 7 tool-tier plugins)* *Net new connectors needed for full parity: ~958* *Recommended Tier 1 (immediate): 15 connectors* *Recommended Tier 2 (next quarter): ~30 connectors* --- --- url: /docs/guides/public-apps.md --- # Public Apps and Routes Public apps let your plugin ship real public web pages served by PIE. Use them for forms, onboarding flows, landing pages, dashboards, and shareable workflows where someone outside the PIE app needs a URL. Public pages are authored inside the same `.pie` file as your tool and widget code. PIE deploys the public files automatically when you save or import the plugin. If you want a copyable end-to-end starter, begin with [Your First Public App](/getting-started/your-first-public-app). This guide is the lower-level reference for packaging, routing, uploads, and domains. ## Widgets vs Public Apps | Surface | Audience | URL | Client API | Server API | |---------|----------|-----|------------|------------| | Widget | Authenticated PIE user | Inside the PIE app | `PIE.onData()`, `PIE.sendAction()`, `PIE.resize()`, `PIE.onTheme()` | `context.widget` | | Public app | Anyone with the link | `/apps/{pluginSlug}/{instanceSlug}/...` or a custom domain | Standard browser APIs (`fetch`, DOM, History API, etc.) | `context.publicApp` via public actions | Public pages do **not** get the PIE widget SDK injected. They are normal browser pages. If your frontend code needs to talk to plugin code, call the public actions API from the browser. ## Authoring in a Single `.pie` File Public apps use extra fenced sections inside a normal `.pie` file: * `===public-config===` defines the entry file and route behavior * `===public-file:path===` defines each public asset * `===widget===` is still optional and can coexist with public files Example: ```text --- name: public-demo displayName: Public Demo tier: tool version: 1.0.0 manifest: trigger: auto widget: launchable: true tool: name: public_demo description: Manage a public demo app parameters: type: object properties: action: type: string enum: [show] required: [action] --- async function handler(input, context) { if (input._publicAction) { switch (input.actionId) { case 'load_page': { context.publicApp.respond({ ok: true, message: 'Hello from public action' }); return { success: true }; } } } if (input.action === 'show') { await context.widget.show({ event: 'ready' }); return { success: true }; } } module.exports = { handler }; ===public-config=== entry: index.html routes: - path: / spa: true ===public-config=== ===public-file:index.html===

Public Demo

===public-file:index.html=== ===public-file:app.js=== fetch('/api/public-actions/INSTANCE_SLUG/load_page', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({}) }).then(r => r.json()).then(console.log); ===public-file:app.js=== ``` ## `public-config` The `public-config` section controls how PIE serves your public bundle: ```yaml entry: index.html routes: - path: / spa: true - path: admin spa: true ``` ### Fields | Field | Type | Description | |-------|------|-------------| | `entry` | string | Entry HTML file to serve for the root URL. Usually `index.html`. | | `routes` | array | Optional route rules for SPA fallback behavior. | | `routes[].path` | string | Route prefix that should resolve to the entry file when no static file matches. | | `routes[].spa` | boolean | If `true`, unknown paths under the route serve the entry file instead of returning 404. | ### SPA Fallback If you set: ```yaml routes: - path: / spa: true ``` Then these URLs all serve your entry HTML if there is no matching static file: * `/apps/my-plugin/my-instance/` * `/apps/my-plugin/my-instance/form/abc` * `/apps/my-plugin/my-instance/settings/theme` If you request a real file like `/apps/my-plugin/my-instance/logo.png`, PIE serves that file directly. ## URL Structure Hosted public apps use this format: ```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 You can also map a public app instance to a custom domain. Once verified, the same bundle is served from the domain root: * `https://forms.example.com/` * `https://forms.example.com/thank-you` PIE exposes authenticated management endpoints for this flow: * `POST /api/public-apps/domains` * `POST /api/public-apps/domains/:id/verify` The platform verifies ownership with a TXT record, then serves the app by `Host` header. ## Calling Plugin Code from a Public Page Use browser `fetch()` to call: ```text POST /api/public-actions/{instanceIdOrSlug}/{actionId} ``` Browser example: ```html ``` ## Routing Public Actions in Your Handler Public actions are just another handler path: ```js async function handler(input, context) { if (input._publicAction) { switch (input.actionId) { case '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 }; } case 'submit_form': { await context.managedDb.query( 'INSERT INTO submissions (form_id, payload) VALUES ($1, $2)', [input.payload.formId, JSON.stringify(input.payload.answers || {})] ); context.publicApp.respond({ ok: true }); return { success: true }; } } } if (input._widgetAction) { // widget-only actions } switch (input.action) { // tool actions called by the AI } } ``` ## Exposing Public Actions as Heartbeat Events Public actions can also power Heartbeats. This is especially useful for visitor-driven events like submissions, signups, approvals, or payments. Add curated public-action matchers to `manifest.heartbeatEvents`: ```json { "heartbeatEvents": { "events": [ { "id": "submission_received", "displayName": "New form submission", "description": "Fires when a visitor submits the public form.", "enabled": true, "matchers": [ { "source": "public", "actionId": "submit_form" } ] } ] } } ``` PIE can suggest these automatically when you save the plugin, but only the approved manifest catalog is exposed to users. ### Public Action Input When PIE invokes your handler for a public request, you receive: ```js { _publicAction: true, actionId: 'submit_form', payload: { ... }, instanceId: 'public-app-instance-id-or-slug', visitorIp: '...' } ``` ## `context.publicApp` `context.publicApp` means slightly different things depending on runtime. ### In Widget Runtimes When your plugin already has a published public app instance for the current developer/user, PIE injects deployment metadata into widget actions: ```js context.publicApp // { // instanceId: '...', // instanceSlug: 'pie-forms-e8e807f3', // pluginSlug: 'pie-forms', // baseUrl: 'https://your-pie-domain.com' // } ``` This is useful for generating share links inside widgets: ```js const shareUrl = context.publicApp.baseUrl + '/apps/' + context.publicApp.pluginSlug + '/' + context.publicApp.instanceSlug; ``` ### In Public Action Runtimes Inside `input._publicAction` handling, `context.publicApp` exposes request-level information: ```js context.publicApp // { // instanceId: '...', // visitorIp: '...', // respond(data) { ... } // } ``` Use `context.publicApp.respond(data)` to set the JSON payload returned to the browser: ```js context.publicApp.respond({ success: true, message: 'Saved' }); return { success: true }; ``` If you do not call `respond()`, PIE returns your handler result as `data`. ## Data Access and Security Public actions are anonymous. There is no authenticated PIE end-user in the browser request. Important implications: 1. `context.managedDb.query()` still works, but it runs against the plugin owner's managed database identity. 2. `context.user.id` in a public action is the plugin owner's user ID, not the anonymous visitor. 3. You must decide what a visitor is allowed to read or write by validating route params, payload fields, slugs, tokens, or form IDs in your own tables. Good pattern: * Store public objects with their own IDs or slugs (`form_id`, `survey_slug`, `publish_token`) * Validate those values explicitly in every public action * Return only the fields the browser actually needs Avoid: * Trusting raw visitor input * Returning internal config or secrets to the page * Assuming managed DB RLS gives you visitor-level isolation Public actions are rate-limited per instance and IP, but you should still validate payload shape and required fields yourself. ## Anonymous File Uploads Public pages can upload files through the platform route: ```text POST /api/public-actions/{instanceIdOrSlug}/upload Content-Type: multipart/form-data ``` Browser example: ```js const formData = new FormData(); formData.append('file', file); const response = await fetch(`/api/public-actions/${encodeURIComponent(instanceId)}/upload`, { method: 'POST', body: formData, }); const result = await response.json(); // result.data => { fileId, filename, url } ``` ### Upload Behavior * Files are stored in the plugin owner's PIE file storage * The route returns `{ fileId, filename, url }` * You should store `fileId` (and any other metadata you need) in your own database tables * The signed `url` is temporary; fetch a fresh URL later if you need a new download link ### Current Limits * Max size: 10 MB per upload * One file per request * Allowed types include images, PDF, text, audio, video, Office docs, zip, JSON, and XML ## Deployment Flow When you save or import a `.pie` file with public files: 1. PIE parses the `public-config` and `public-file` sections 2. PIE uploads the bundle to storage 3. PIE creates or updates the active public app deployment 4. PIE creates or reuses a public app instance for the plugin owner 5. PIE returns a `publicAppUrl` in the plugin import/update response This means you can keep authoring everything in a single `.pie` file while PIE handles the multi-file deployment behind the scenes. ## Current File Limits Public app bundles currently have these authoring limits: * Max 50 public files * Max 512 KB per file * Max 2 MB total bundle size * Paths must be relative, must not start with `/`, and must not contain `..` ## Recommended Architecture For most public experiences, use this split: 1. **Widget** for the authenticated builder/editor used by the plugin owner 2. **Public app** for the shareable page visitors see 3. **Public actions** for anonymous reads/writes from that page 4. **Managed DB** for plugin-owned data 5. **PIE file storage** for uploads initiated by public visitors That pattern gives you a real public frontend without forcing you to deploy a separate app server. --- --- url: /docs/guides/security-scanning.md --- # Security Scanning Every agent plugin on PIE undergoes automated security analysis. This page explains how the system works, what it checks, and how to ensure your plugin passes. ## How It Works When you create, update, or import a plugin, PIE automatically runs a security scan using an AI-powered code analysis system (Claude Opus 4.6 via OpenRouter). The scan evaluates your plugin code and manifest against a set of security criteria. Each scan produces: * **Pass/Fail** result for each of the 10 security criteria * An **overall pass/fail** status (all criteria must pass for an overall pass) * A **summary** with a brief explanation of the findings ## When Scans Run Scans are triggered automatically in these situations: | Trigger | When | |---------|------| | **Plugin created** | When a new plugin with code or a prompt template is created | | **Plugin updated** | When code, prompt template, or manifest changes | | **Plugin imported** | When a `.pie` file is imported via the Developer Portal | | **Manual trigger** | When a developer clicks "Run Scan" in the Security tab | Auto-triggered scans use code hashing (SHA-256) to avoid redundant scans -- if the code hasn't changed since the last scan, the scan is skipped. ## Security Criteria Your plugin is evaluated against these 10 criteria: ### 1. No Data Exfiltration Code must not send user data, conversation content, or secrets to unauthorized external endpoints. Your plugin should only communicate with APIs relevant to its stated purpose. ### 2. No Credential Harvesting Code must not capture, log, or exfiltrate API keys, tokens, passwords, or secrets. Using `context.secrets` for your plugin's own API keys is perfectly fine. ### 3. No Filesystem Abuse Code must not attempt to access, read, or write files outside its sandbox scope. Path traversal patterns and attempts to access system files will be flagged. ### 4. No Code Injection Code must not use `eval()`, `new Function()`, or other dynamic code execution with untrusted input. Safe uses of `JSON.parse()` are acceptable. ### 5. No Network Abuse Code must only make requests to hosts related to its stated purpose. A GitHub plugin calling the GitHub API is fine; a weather plugin calling an unknown endpoint is not. ### 6. No Crypto Mining / Resource Abuse Code must not contain cryptocurrency mining, intentional infinite loops, or deliberate resource exhaustion patterns. ### 7. No Obfuscated Code Code must be readable and not contain meaningfully obfuscated or encoded payloads designed to hide behavior. Base64-encoded config strings with clear purpose are acceptable. ### 8. No Privacy Violations Code must not collect, store, or transmit PII beyond what is necessary for its stated function. ### 9. No Privilege Escalation Code must not attempt to break out of its sandbox, access the host process, or escalate permissions. ### 10. Safe Dependencies Any referenced external URLs or APIs must be legitimate, well-known services matching the plugin's stated purpose. ## Viewing Results ### Marketplace & Plugin Pages Every plugin displays a security badge: * **Verified** (green) -- All criteria passed * **Security Issues** (red) -- One or more criteria failed * **Scanning...** (yellow) -- Scan in progress * **Not Scanned** (gray) -- No scan has been run yet On the plugin detail page, you can expand the full security report to see individual criteria results with explanations. ### Developer Portal In the Developer Portal, the **Security** tab shows: * Current scan status and summary * Detailed breakdown of all 10 criteria with pass/fail indicators * Scan history showing previous scan results * A **Run Scan** button to manually trigger a new scan ## Tips for Passing 1. **Only call APIs relevant to your plugin's purpose** -- If your plugin is a weather tool, only call weather APIs 2. **Use `context.secrets` for credentials** -- Never hardcode API keys or tokens 3. **Use `context.oauth.fetch()` for authenticated requests** -- This is the approved way to make OAuth-authenticated calls 4. **Write readable code** -- Avoid unnecessary string encoding or variable name obfuscation 5. **Declare network access in your manifest** -- List the hosts your plugin needs to access 6. **Don't use `eval()` or `new Function()`** -- Use standard JavaScript patterns instead ## API Endpoints | Method | Endpoint | Auth | Description | |--------|----------|------|-------------| | `GET` | `/api/plugins/:id/security` | No | Get latest scan result | | `POST` | `/api/plugins/:id/security/scan` | Yes (author) | Manually trigger a scan | | `GET` | `/api/plugins/:id/security/history` | Yes (author) | Get scan history | --- --- url: /docs/reference/tool-parameters.md --- # Tool Parameters Tool parameters define what inputs your tool accepts. They're specified using JSON Schema format. ## Basic Structure ```json { "tool": { "name": "tool_name", "description": "What the tool does", "parameters": { "type": "object", "properties": { "param1": { ... }, "param2": { ... } }, "required": ["param1"] } } } ``` ## Parameter Types ### String ```json { "city": { "type": "string", "description": "City name (e.g., 'London', 'Tokyo')" } } ``` ### Number ```json { "temperature": { "type": "number", "description": "Temperature in Celsius" } } ``` ### Integer ```json { "count": { "type": "integer", "description": "Number of items to return" } } ``` ### Boolean ```json { "includeDetails": { "type": "boolean", "description": "Whether to include detailed information" } } ``` ### Array ```json { "tags": { "type": "array", "items": { "type": "string" }, "description": "List of tags to filter by" } } ``` ### Object ```json { "filters": { "type": "object", "properties": { "minPrice": { "type": "number" }, "maxPrice": { "type": "number" } }, "description": "Filter criteria" } } ``` ## Enums Restrict values to a predefined set: ```json { "action": { "type": "string", "enum": ["search", "read", "send", "delete"], "description": "The action to perform" } } ``` The AI will only use these exact values. ## Required Parameters List parameters that must be provided: ```json { "parameters": { "type": "object", "properties": { "query": { "type": "string", "description": "Search query" }, "maxResults": { "type": "integer", "description": "Max results" } }, "required": ["query"] } } ``` `query` is required, `maxResults` is optional. ## Descriptions Write clear descriptions. The AI uses these to understand when and how to use each parameter. **Good:** ```json { "query": { "type": "string", "description": "Gmail search query using Gmail's search syntax (e.g., 'from:john@example.com subject:meeting newer_than:7d')" } } ``` **Bad:** ```json { "query": { "type": "string", "description": "Search query" } } ``` ## Defaults in Code Handle optional parameters with defaults in your code: ```js async function handler(input, context) { const { query, maxResults = 10, // Default value includeArchived = false } = input; // ... } ``` ## Complex Example ```json { "tool": { "name": "search_products", "description": "Search for products in the catalog", "parameters": { "type": "object", "properties": { "query": { "type": "string", "description": "Search text to match product names and descriptions" }, "category": { "type": "string", "enum": ["electronics", "clothing", "home", "sports"], "description": "Product category to filter by" }, "priceRange": { "type": "object", "properties": { "min": { "type": "number", "description": "Minimum price in USD" }, "max": { "type": "number", "description": "Maximum price in USD" } }, "description": "Price range filter" }, "tags": { "type": "array", "items": { "type": "string" }, "description": "Product tags to filter by" }, "sortBy": { "type": "string", "enum": ["price_asc", "price_desc", "newest", "popular"], "description": "How to sort results" }, "limit": { "type": "integer", "description": "Maximum number of results (default: 20)" } }, "required": ["query"] } } } ``` ## Tips 1. **Required first** - List commonly-used required params first 2. **Sensible defaults** - Make optional params work well with defaults 3. **Clear enums** - Use descriptive enum values 4. **Complete descriptions** - Include examples and valid values 5. **Flat when possible** - Nested objects are harder for the AI to fill ## How the AI Uses Parameters When the AI decides to call your tool: 1. It reads the tool's `description` 2. It reads each parameter's `description` 3. It constructs the input object 4. PIE validates against your schema 5. Your handler receives the `input` object ```js // User: "What's the weather in Tokyo?" // AI constructs: { city: "Tokyo" } async function handler(input, context) { const { city } = input; // "Tokyo" // ... } ``` --- --- url: /docs/examples/weather-tool.md --- # Weather Tool Example A complete example of a tool that fetches weather data from an API. ## Overview * **Type:** Tool * **Trigger:** Auto (AI decides when to use) * **API:** [WeatherAPI.com](https://www.weatherapi.com/) ## Full Code ```js /** * Weather Tool - Get current weather for any city * * Demonstrates: * - Using context.fetch() for API calls * - Accessing developer secrets * - Input validation * - Error handling * - Structured return values */ async function handler(input, context) { const { city, units = 'metric' } = input; // Validate input if (!city) { return { error: true, message: 'City is required. Please specify a city name.' }; } // Get API key from secrets const apiKey = context.secrets.WEATHER_API_KEY; if (!apiKey) { return { error: true, message: 'Weather API key not configured. Please add your WeatherAPI.com key.' }; } try { // Make API request const response = await context.fetch( `https://api.weatherapi.com/v1/current.json?key=${apiKey}&q=${encodeURIComponent(city)}&aqi=yes` ); // Handle HTTP errors if (!response.ok) { if (response.status === 400) { return { error: true, message: `City "${city}" not found.` }; } if (response.status === 401) { return { error: true, message: 'Invalid API key.' }; } return { error: true, message: `Weather API error: ${response.status}` }; } // Parse response const data = JSON.parse(response.body); // Format response based on units preference const useMetric = units === 'metric'; return { location: { city: data.location.name, region: data.location.region, country: data.location.country, localTime: data.location.localtime, }, current: { temperature: useMetric ? data.current.temp_c : data.current.temp_f, temperatureUnit: useMetric ? '°C' : '°F', feelsLike: useMetric ? data.current.feelslike_c : data.current.feelslike_f, condition: data.current.condition.text, humidity: data.current.humidity, windSpeed: useMetric ? data.current.wind_kph : data.current.wind_mph, windSpeedUnit: useMetric ? 'km/h' : 'mph', windDirection: data.current.wind_dir, uvIndex: data.current.uv, visibility: useMetric ? data.current.vis_km : data.current.vis_miles, visibilityUnit: useMetric ? 'km' : 'miles', }, airQuality: data.current.air_quality ? { usEpaIndex: data.current.air_quality['us-epa-index'], pm25: data.current.air_quality.pm2_5, pm10: data.current.air_quality.pm10, } : null, }; } catch (error) { return { error: true, message: error.message || 'Failed to fetch weather data' }; } } module.exports = { handler }; ``` ## Manifest ```json { "trigger": "auto", "developerSecrets": { "WEATHER_API_KEY": { "description": "API key from weatherapi.com (free tier available)", "required": true } }, "tool": { "name": "get_weather", "description": "Get current weather conditions for any city. Returns temperature, humidity, wind, and air quality. Use when users ask about weather, temperature, or outdoor conditions.", "parameters": { "type": "object", "properties": { "city": { "type": "string", "description": "City name (e.g., 'London', 'New York', 'Tokyo'). Can include country for clarity: 'Paris, France'" }, "units": { "type": "string", "enum": ["metric", "imperial"], "description": "Temperature and speed units. 'metric' for Celsius/km, 'imperial' for Fahrenheit/miles. Default: metric" } }, "required": ["city"] } } } ``` ## Setup 1. **Get API Key** * Sign up at [WeatherAPI.com](https://www.weatherapi.com/) * Free tier: 1 million calls/month * Copy your API key 2. **Create Agent** * Go to Agents in PIE * Click "Create Agent" * Select "Tool" tier * Paste the code and manifest 3. **Add Secret** * After creating, go to agent settings * Add your `WEATHER_API_KEY` 4. **Test** * Enable the agent * Ask PIE: "What's the weather in Tokyo?" ## Example Interaction **User:** What's the weather like in London? **PIE calls:** `get_weather({ city: "London" })` **Agent returns:** ```json { "location": { "city": "London", "region": "City of London, Greater London", "country": "United Kingdom", "localTime": "2024-01-15 14:30" }, "current": { "temperature": 8, "temperatureUnit": "°C", "feelsLike": 5, "condition": "Partly cloudy", "humidity": 76, "windSpeed": 19, "windSpeedUnit": "km/h", "windDirection": "WSW", "uvIndex": 1, "visibility": 10, "visibilityUnit": "km" } } ``` **PIE responds:** "In London, it's currently 8°C (feels like 5°C) with partly cloudy skies. The humidity is 76% with winds from the WSW at 19 km/h." ## Key Patterns ### Input Validation ```js if (!city) { return { error: true, message: 'City is required' }; } ``` ### Secret Validation ```js const apiKey = context.secrets.WEATHER_API_KEY; if (!apiKey) { return { error: true, message: 'API key not configured' }; } ``` ### URL Encoding ```js encodeURIComponent(city) // "New York" → "New%20York" ``` ### Error Handling ```js if (!response.ok) { if (response.status === 400) { return { error: true, message: `City "${city}" not found.` }; } // ... } ``` ### Structured Return Return clean, structured data that the AI can easily understand and summarize. --- --- url: /docs/guides/widget-actions.md --- # 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](/guides/public-apps). 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 ``` ### 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](/reference/widget-ui-catalog). --- --- url: /docs/reference/widget-ui-catalog.md --- # Widget UI Catalog A premium, standardized design system for PIE plugin widgets. Use this catalog as your single source of truth for building polished, consistent widget UIs — whether you are writing HTML by hand or using the AI builder. This page is specifically for the authenticated `===widget===` surface inside PIE. If you are building a public website or shareable route at `/apps/...`, use the [Public Apps and Routes guide](/guides/public-apps) for packaging, routing, and public action patterns. Widgets are sandboxed iframes. They communicate with the host via `PIE.onData`, `PIE.sendAction`, `PIE.resize`, and `PIE.onTheme`. All CSS, HTML, and JS live inside the `===widget===` section of your `.pie` file. *** ## Foundations ### Design Tokens Every widget should define its palette through CSS custom properties on `:root`. These tokens are aligned with the PIE host app's design system so widgets look native inside the shell. ```html ``` ### Dark Mode via PIE.onTheme Use `PIE.onTheme` to respond to the host app's theme. Dark mode uses PIE's deep dark palette — near-black with a subtle cool tint. ```html ``` ### Base Reset and Body ```html ``` ### Premium Rules * Never show raw browser defaults (unstyled selects, default checkboxes, system fonts). * Use strong visual hierarchy: one dominant element per section, muted secondary text, restrained accents. * Minimum touch target: 32px height for interactive elements. * Use `var(--fg-secondary)` for labels and metadata, `var(--fg-muted)` for timestamps and hints. * Limit accent color to primary actions and active states. Everything else is neutral. * Animate state changes with `transition: all var(--duration-normal) var(--easing)`. * Use `var(--shadow-sm)` for cards at rest, `var(--shadow-md)` on hover. * Use frosted glass effects (`backdrop-filter: blur()`) for overlays and sticky headers where appropriate. * Scrollbars should be slim and translucent — never default system scrollbars. * Enable Inter font features: `font-feature-settings: 'cv11', 'ss01'`. ### Widget Contract ``` manifest.widget: allowedScripts: [] # HTTPS CDN URLs if needed allowedStyles: [] # HTTPS CDN URLs if needed maxHeight: 100–800 # pixels, enforced by host defaultHeight: 280 # initial iframe height launchable: true|false # show in agent launcher ``` * `PIE.onData(callback)` — receive data from handler via `context.widget.show()` / `context.widget.update()` * `PIE.sendAction(actionId, payload)` — trigger handler with `input._widgetAction = true` * `PIE.resize(height)` — request height change (clamped to `maxHeight` and 800px) * `PIE.onTheme(callback)` — receive `(theme, colors)` from host * Action responses arrive as `PIE.onData` with `data.__actionMeta: { ok, error, result }` *** ## Primitive Components ### Buttons ```html ``` ### Inputs ```html ``` ### Tabs and Navigation ```html ``` ### Cards and Display ```html ``` ### Feedback and States ```html ``` *** ## Workflow Patterns Workflow patterns standardize interaction flows — not just components, but the sequence of states and events that a widget moves through. Each pattern maps to `PIE.sendAction`, `PIE.onData`, and `__actionMeta`. ### Search → Filter → Inspect → Act ``` User types query → sendAction('search', { query, filters }) Widget shows loading → onData({ type: 'results', items }) → render list User clicks item → local state update → render detail User clicks action → sendAction('act', { id }) → __actionMeta → toast ``` **States:** idle, searching, results, no-results, detail, acting, success, error ### Queue → Review → Approve/Reject → Next ``` onData({ type: 'queue', items, total }) User reviews top item → clicks approve/reject sendAction('approve', { id }) → __actionMeta → advance to next When queue empty → onData({ type: 'queue', items: [] }) → empty state ``` **States:** loading, reviewing, acting, empty, error ### Generate → Preview → Accept/Regenerate ``` onData({ type: 'generating', progress }) → progress bar onData({ type: 'result', output }) → render preview User accepts → sendAction('accept', { id }) User regenerates → sendAction('regenerate', { id }) → back to generating ``` **States:** idle, generating, preview, accepting, regenerating, done, error ### Connect → Authorize → Validate → Success ``` Widget starts → check connection state via onData Not connected → show connect button sendAction('connect') → host opens OAuth → onData({ type: 'connected' }) Validate → sendAction('validate') → success or retry ``` **States:** checking, disconnected, connecting, connected, validating, ready, error ### Run Job → Progress → Complete/Retry ``` sendAction('start_job', { params }) → __actionMeta { ok } onData({ type: 'progress', percent, message }) → progress bar onData({ type: 'complete', result }) → show result onData({ type: 'failed', error }) → show error + retry button User retries → sendAction('retry', { jobId }) ``` **States:** idle, running, progress, complete, failed, retrying ### Browse → Select → Compare → Confirm ``` onData({ type: 'items', items }) → grid/list User selects items → local state (selectedIds) User opens comparison → render side-by-side User confirms → sendAction('confirm', { selectedId }) ``` **States:** browsing, selecting, comparing, confirming, confirmed ### Create/Edit → Autosave → Publish ``` Widget loads document → onData({ type: 'document', content }) User edits → debounced sendAction('save', { content }) → __actionMeta User publishes → sendAction('publish', { id }) → __actionMeta → banner ``` **States:** loading, editing, saving, saved, publishing, published, error ### Install → Configure → Validate → Launch ``` First load → onData({ type: 'setup', step }) or onData({ type: 'ready' }) If setup needed → show config fields sendAction('configure', { settings }) → validate → onData({ type: 'ready' }) ``` **States:** checking, needs-setup, configuring, validating, ready, error ### Monitor → Investigate → Resolve ``` onData({ type: 'status', items, alerts }) → dashboard User clicks alert → detail view sendAction('resolve', { alertId, action }) → __actionMeta ``` **States:** monitoring, investigating, resolving, resolved *** ## Screen Templates A screen template is a reusable page/workspace structure. It defines layout, hierarchy, action placement, and default states. The same template powers multiple domains by swapping the nouns and data. Every screen template below includes cross-domain examples showing how it generalizes. ### Master-Detail Screen **Layout:** list on the left/top, detail pane on the right/bottom. Selecting an item updates the detail. **Cross-domain examples:** * Plugin Marketplace: plugin list → plugin detail with install * HubSpot CRM: contact/deal list → record detail with inline edit * File Browser: document list → preview and metadata ```html ``` ### Search + Filters + Results Screen **Layout:** sticky header with search and filter controls, scrollable results area, optional footer with pagination or stats. **Cross-domain examples:** * Travel Search: flights/hotels with price and date filters * Vehicle Search: cars with make/model/price filters * Job Board: positions with location/salary/role filters ```html ``` ### KPI Dashboard Screen **Layout:** headline stats row, chart/content area below, optional action strip. **Cross-domain examples:** * Finance Analyst: stock quote + price chart + fundamentals * Marketing Dashboard: traffic, conversions, revenue metrics * Operations Console: uptime, error rates, queue depths ```html ``` ### Table + Inspector Screen **Layout:** full-width data table with optional side inspector for the selected row. **Cross-domain examples:** * Postgres Analysis: query results with row inspector * SEO Audit: pages with metrics table and detail drawer * Inventory: stock items with quantities and supplier detail ```html ``` ### Document Editor Screen **Layout:** toolbar at top, editor workspace center, optional preview or sidebar. **Cross-domain examples:** * MarkPad: markdown editor with live preview * Blog Agent: post editor with metadata sidebar * Slide Deck: outline editor with slide preview ```html ``` ### Queue / Review Screen **Layout:** queue list with review controls. Items are processed one at a time with approve/reject/skip actions. **Cross-domain examples:** * LinkedIn Engagement: pending actions with approve/reject * Content Moderation: flagged items with resolution controls * AI Output Review: generated results with accept/regenerate ```html ``` ### Background Job Monitor Screen **Layout:** progress indicator, status message, result or error display with retry action. **Cross-domain examples:** * Slide Deck Creator: generation progress with slide-by-slide status * Data Export: processing progress with download link * Bulk Operations: batch processing with item-level results ```html ``` ### Settings / Setup Screen **Layout:** vertical form sections with labels, inputs, and validation states. Optional onboarding checklist. **Cross-domain examples:** * OAuth Connection: connect account flow * Plugin Configuration: API key and preferences setup * First-Run Onboarding: step-by-step checklist ```html ``` ### Launchable Workspace Screen **Layout:** full-panel chrome for standalone mini-apps. Header with title and navigation, main content area, optional footer actions. **Cross-domain examples:** * MarkPad: full document browser and editor * Todo List: grouped task manager * Cursor Cloud Agents: agent list → detail → launch form ```html ``` ### Compact Inline Screen **Layout:** minimal chrome for inline chat widgets. No header bar, just padded content with optional compact footer. **Cross-domain examples:** * Weather Tool: current conditions card * Calculator: result display * Quick Status: single metric or confirmation ```html ``` *** ## Data Contracts ### Standard onData Payload Shape ```js 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': case 'loaded': break; case 'item_added': case 'item_updated': case 'item_removed': break; case 'progress': break; case 'error': showBanner('error', data.message); break; default: break; } render(); }); ``` ### Standard sendAction Pattern ```js function doAction(actionId, payload) { setLoading(actionId, true); PIE.sendAction(actionId, payload || {}); } ``` ### Standard Resize Pattern ```js function autoResize() { var body = document.getElementById('app'); var h = Math.min(Math.max(body.scrollHeight + 24, 200), 600); PIE.resize(h); } ``` ### Flash / Toast Helper ```js function showToast(type, message, duration) { var el = document.createElement('div'); el.className = 'toast toast-' + type; el.textContent = message; document.body.appendChild(el); setTimeout(function() { el.remove(); }, duration || 3000); } ``` *** ## Layout Utilities ```html ``` --- --- url: /docs/guides/working-with-files.md --- # Working with Files PIE provides a file storage API that lets your agent upload, list, and manage files in the user's account. Files are isolated per agent by default, and documents are automatically indexed so the AI can search them. ## Quick start ```js async function handler(input, context) { // Upload a file const file = await context.files.upload( 'data.csv', 'text/csv', btoa('name,value\nAlice,100\nBob,200') ); return { success: true, file, // Display inline in chat if it's an image message: `Uploaded ${file.filename} (${file.tags.join(', ')})`, }; } module.exports = { handler }; ``` ## How it works When your agent calls `context.files.upload()`: 1. **Quota check** -- PIE verifies the user hasn't exceeded the 250 MB limit for your agent. 2. **AI metadata** -- Gemini generates a descriptive filename, tags, and a one-line description. 3. **GCS upload** -- The file is stored securely in Google Cloud Storage under the user's account. 4. **File Search indexing** -- If the file is a document type (PDF, text, CSV, etc.), it's automatically indexed in the user's knowledge base so the AI can reference it in chat. 5. **Database record** -- A record is created with metadata, tags, and access control. ## Supported file types ### Automatically indexed (searchable by AI) Documents uploaded via `context.files` are indexed if they match these types: * **Documents**: PDF, DOCX, DOC, ODT, PPTX * **Text**: TXT, MD, JSON, XML, YAML, CSV, TSV * **Code**: JS, TS, PY, Java, Go, Rust, C, C++, PHP, Ruby, and more * **Spreadsheets**: XLSX, XLS ### Stored only (not indexed) Media files are stored in GCS but not indexed in File Search: * **Images**: JPEG, PNG, GIF, WebP, SVG * **Video**: MP4, WebM * **Audio**: MP3, WAV, OGG, FLAC ## Displaying files in chat ### Images Return a `file` object in your handler result and PIE will display it inline: ```js async function handler(input, context) { const imageBase64 = await generateChart(input.data); const uploaded = await context.files.upload( 'chart.png', 'image/png', imageBase64 ); return { success: true, file: uploaded, description: uploaded.description, }; } ``` PIE detects `result.file` and renders images as `![description](/api/files/{id}/view)` in the message. The URL never expires because it generates a fresh signed URL on each request. ### Documents and other files For non-image files, PIE includes the file info in the AI's response. The AI can then describe what was uploaded. The file will also appear on the user's Files page. ## Listing and managing files ```js // List all files your agent can access const files = await context.files.list(); // Filter by type const images = await context.files.list('image/*'); const docs = await context.files.list('application/pdf'); // Get a download URL (valid ~15 minutes) const url = await context.files.getUrl(fileId); // Delete a file your agent created await context.files.delete(fileId); ``` ## Access control * **Isolation**: By default, each agent can only see files it created. * **Cross-agent access**: Users can grant other agents access to specific files via the PIE Files page. * **User ownership**: Users can always see and delete all agent files from their Files page. * **Cleanup on uninstall**: When a user removes your agent, they choose whether to keep or delete the files. ## Quotas * **250 MB** per agent per user (configurable by the PIE administrator) * Check current usage before large uploads * An error with details is thrown if the quota is exceeded ```js try { await context.files.upload('large-file.zip', 'application/zip', base64Data); } catch (err) { if (err.message.includes('quota')) { return { error: true, message: 'Storage limit reached. Please delete some files.' }; } throw err; } ``` ## Native E2B agents If you're writing a native E2B agent (without the `context` shim), use the Plugin Bridge HTTP API directly: ```js const API_BASE = process.env.PIE_API_BASE; const TOKEN = process.env.PIE_API_TOKEN; // Upload const res = await fetch(`${API_BASE}/api/plugin-bridge/files/upload`, { method: 'POST', headers: { 'Authorization': `Bearer ${TOKEN}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ filename: 'data.csv', mimeType: 'text/csv', data: btoa('name,value\nAlice,100'), }), }); const file = await res.json(); // List files const listRes = await fetch(`${API_BASE}/api/plugin-bridge/files`, { headers: { 'Authorization': `Bearer ${TOKEN}` }, }); const { files } = await listRes.json(); // Get signed URL const urlRes = await fetch(`${API_BASE}/api/plugin-bridge/files/${fileId}/url`, { headers: { 'Authorization': `Bearer ${TOKEN}` }, }); const { url } = await urlRes.json(); // Delete await fetch(`${API_BASE}/api/plugin-bridge/files/${fileId}`, { method: 'DELETE', headers: { 'Authorization': `Bearer ${TOKEN}` }, }); ``` ## Best practices 1. **Use descriptive filenames** -- Even though PIE renames files with AI, a meaningful original name helps the AI generate better metadata. 2. **Upload documents for AI access** -- If your agent fetches data the user might want to ask about later, upload it as a text/PDF file so it gets indexed. 3. **Clean up temporary files** -- Delete files your agent no longer needs to stay within quota. 4. **Handle quota errors gracefully** -- Always catch upload errors and provide a helpful message. 5. **Return `file` in results for inline display** -- For images and charts, return the uploaded file object in your handler result so it displays in chat. --- --- url: /docs/getting-started/your-first-automation.md --- # Your First Automation Automations are agents that run on a schedule (cron) or in response to external events (webhooks). Unlike tools, automations are not called by the AI - they run in the background. ## What Makes an Automation Different? | Aspect | Tool | Automation | |--------|------|------------| | **Triggered by** | AI decides to call it | Cron schedule, webhook, or lifecycle hook | | **Manifest field** | `tool: {...}` | `automation: {...}` | | **AI access** | AI can use it | Can call AI via `context.ai` | | **Extra context** | Standard context | `context.ai`, `context.notify`, `context.input` | | **Output** | Returns result to AI | Posts to PIE Assistant session | ## Example: Daily Weather Alert Let's build a simple automation that sends a daily weather notification: ```javascript async function handler(input, context) { // Get weather from an API const response = await context.fetch( `https://api.weather.com/v1/current?city=NYC&apiKey=${context.secrets.WEATHER_API_KEY}` ); if (!response.ok) { throw new Error('Failed to fetch weather'); } const weather = JSON.parse(response.body); // Post notification to PIE Assistant await context.notify( `☀️ **Today's Weather**\n\n` + `Temperature: ${weather.temp}°F\n` + `Conditions: ${weather.conditions}\n` + `Humidity: ${weather.humidity}%`, { title: 'Daily Weather', } ); return { success: true, temp: weather.temp }; } module.exports = { handler }; ``` ## The Automation Manifest ```json { "name": "daily-weather", "displayName": "Daily Weather", "tier": "automation", "automation": { "triggers": [ { "type": "cron", "default": "0 7 * * *", "description": "Daily at 7am" } ], "timeout": 30 }, "developerSecrets": { "WEATHER_API_KEY": { "description": "API key for weather service", "required": true } } } ``` ## Trigger Types ### Cron Triggers Schedule automations using cron expressions: ```json { "automation": { "triggers": [ { "type": "cron", "default": "*/15 * * * *", "description": "Every 15 minutes" } ] } } ``` Common cron expressions: * `0 7 * * *` - Daily at 7am * `*/15 * * * *` - Every 15 minutes * `0 9 * * 1-5` - Weekdays at 9am * `0 0 1 * *` - First day of each month **Note:** Cron times use the user's configured timezone. ### Webhook Triggers Receive external events via HTTP: ```json { "automation": { "triggers": [ { "type": "webhook" } ] } } ``` When installed, PIE generates a webhook URL for the agent. External services POST to this URL to trigger the automation. **Agent webhook URL:** `/api/webhooks/plugin/{pluginId}` This URL is the same for all users of your agent. Your `onWebhook` handler receives the payload and can determine which user it's for based on the payload content (e.g., email address from Gmail Pub/Sub). ### Declaring Webhook Events You can declare the specific event types your webhook produces. This allows users to create heartbeats that are triggered by specific events from your agent: ```json { "automation": { "triggers": [ { "type": "webhook", "eventTypeField": "type", "events": [ { "name": "invoice.paid", "description": "Invoice successfully paid" }, { "name": "charge.failed", "description": "A charge attempt failed" } ] } ], "onWebhook": true } } ``` * `eventTypeField` — A dot-path to the field in the webhook payload that identifies the event type (e.g., `"type"` for Stripe, `"_headers.x-github-event"` for GitHub). * `events` — The list of event types your webhook can produce. These populate the dropdown when users create event-triggered heartbeats. For automation plugins, these declared webhook events are the built-in way to expose webhook-triggered Heartbeats. If your plugin also has tool actions, widget actions, or public actions that should trigger Heartbeats, add those separately with `manifest.heartbeatEvents`. See the [Manifest Reference](/reference/manifest#declaring-webhook-events) for full details. ## Lifecycle Hooks Agents can export lifecycle handlers that run at specific moments: ### `onInstall` - After Agent Installation Run code immediately when a user installs the agent: ```javascript async function onInstall(input, context) { // Initialize default settings, provision resources, etc. await context.notify('Thanks for installing! Getting things set up...', { title: 'Installation Started' }); return { success: true }; } module.exports = { handler, onInstall }; ``` ### `onUninstall` - Before Agent Removal Run cleanup code when a user uninstalls the agent: ```javascript async function onUninstall(input, context) { // Tear down resources, revoke tokens, remove external subscriptions await context.fetch('https://api.example.com/cleanup', { method: 'POST', body: JSON.stringify({ userId: context.userId }), }); return { success: true }; } module.exports = { handler, onInstall, onUninstall }; ``` ### `onConnect` - After OAuth Connection Run code immediately when a user connects their OAuth account: ```javascript async function onConnect(input, context) { // Set up external subscriptions, welcome the user, etc. await context.notify('Welcome! Your account is now connected.', { title: 'Setup Complete' }); // Example: Set up Gmail push notifications const watchResp = await context.oauth.fetch('https://gmail.googleapis.com/gmail/v1/users/me/watch', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ topicName: context.secrets.PUBSUB_TOPIC, labelIds: ['INBOX'], }), }); return { success: true }; } module.exports = { handler, onInstall, onConnect }; ``` ### `onWebhook` - Handle Incoming Webhooks Process webhook payloads from external services: ```javascript async function onWebhook(input, context) { const payload = input.triggerData; // Decode Pub/Sub message if needed if (payload.message?.data) { const decoded = atob(payload.message.data); const notification = JSON.parse(decoded); // Process notification... } return { success: true }; } module.exports = { handler, onInstall, onConnect, onWebhook }; ``` ### `onDisconnect` - Cleanup (Optional) Run code when a user disconnects OAuth: ```javascript async function onDisconnect(input, context) { // Clean up external subscriptions await context.oauth.fetch('https://api.example.com/unsubscribe', { method: 'POST' }); return { success: true }; } module.exports = { handler, onInstall, onConnect, onDisconnect }; ``` ## Automation Context Automations get additional context APIs not available to tools: ### `context.ai` Call the AI from within your automation: ```javascript // Analyze data with AI const analysis = await context.ai.analyze({ prompt: 'Classify this email as urgent or not urgent', data: { subject, from, snippet } }); // Returns: { label: 'urgent', confidence: 0.95 } // Summarize content const summary = await context.ai.summarize(longText); // Returns: "Brief summary of the content..." ``` ### `context.notify()` Post messages to the user's PIE Assistant session: ```javascript await context.notify('Your notification message', { title: 'Optional Title', urgent: true, // Highlights the notification }); ``` ### `context.input` Access automation-specific information: ```javascript const { lastRunAt, // Timestamp of last successful run (ms) triggeredBy, // 'cron', 'webhook', or 'manual' triggerData, // Webhook payload (if triggered by webhook) } = input; // Process only new data since last run const sinceTime = lastRunAt || (Date.now() - 24 * 60 * 60 * 1000); ``` ## Combining Tool + Automation An agent can be both a tool AND an automation by including both fields: ```json { "name": "weather", "tier": "tool", "tool": { "name": "get_weather", "description": "Get current weather for a location" }, "automation": { "triggers": [ { "type": "cron", "default": "0 7 * * *" } ] } } ``` This allows: * AI to call `get_weather` when the user asks * Daily automated weather notifications ## OAuth in Automations Automations can use OAuth just like connectors: ```json { "automation": { "triggers": [ { "type": "cron", "default": "0 7 * * *" } ] }, "oauth": { "provider": "google", "scopes": ["https://www.googleapis.com/auth/gmail.readonly"], "clientIdSecret": "GOOGLE_CLIENT_ID", "clientSecretSecret": "GOOGLE_CLIENT_SECRET" } } ``` Use `context.oauth.fetch()` for authenticated API calls: ```javascript const response = await context.oauth.fetch( 'https://gmail.googleapis.com/gmail/v1/users/me/messages' ); ``` ## Error Handling If an automation fails 3 times consecutively: 1. It's automatically disabled 2. User receives a notification in PIE Assistant 3. User can re-enable it in the Automations page Handle errors gracefully: ```javascript async function handler(input, context) { try { // Your automation logic } catch (error) { // Notify user of the issue await context.notify( `⚠️ Automation error: ${error.message}\n\nPlease check your configuration.`, { urgent: true } ); throw error; // Re-throw to mark run as failed } } ``` ## Heartbeats Heartbeats are built-in automations that send scheduled messages to the AI assistant — no code required. While agent automations let you run custom code on a schedule, heartbeats provide a simpler alternative: they deliver a preconfigured message to the PIE Assistant session at regular intervals, letting the AI act on it. ### Heartbeats vs. Agent Automations Heartbeats and agent automations share the same scheduling infrastructure. The key difference is what happens when the schedule fires: | | Heartbeat | Agent Automation | |---|-----------|-----------------| | **Requires code** | No | Yes | | **What runs** | Sends a message to the AI assistant | Executes your `handler` function | | **Use case** | Periodic AI prompts ("Check my inbox", "Summarize today's tasks") | Custom logic with API calls, data processing, notifications | | **Setup** | API or UI only | Agent manifest + code | ### Trigger Types Heartbeats support three triggering modes: * **Interval** — Run every X minutes, hours, or days. For example, every 30 minutes or every 2 hours. * **Cron expression** — Standard cron syntax for precise scheduling (e.g., `0 9 * * 1-5` for weekdays at 9am). * **Plugin Event** — Trigger when an installed agent receives a specific webhook event. For example, trigger a heartbeat when GitHub receives a `pull_request` event or when Stripe receives an `invoice.payment_failed` event. The webhook payload is automatically included in the AI prompt as context. When using Plugin Event triggers, the UI shows a two-step picker: first select the agent (e.g., GitHub), then select the specific event type from the events that agent declares. You can also select "Any event" to trigger on all webhook events from that agent. Plugin Event heartbeats have a 5-minute cooldown between triggers to prevent excessive AI calls from chatty webhook sources. ### Active Hours You can optionally restrict a heartbeat to a time window using HH:MM format: * `activeFrom: "09:00"` / `activeTo: "17:00"` — Only fires during business hours. * Overnight ranges are supported: `activeFrom: "22:00"` / `activeTo: "06:00"` fires from 10pm through 6am. When outside the active window, scheduled executions are silently skipped. ### Delivery Heartbeat messages are delivered to the PIE Assistant session by default. The AI receives the message as if a user had typed it, and can respond, trigger tools, or take any action it normally would. ### Error Handling Like agent automations, heartbeats auto-disable after 3 consecutive failures. The user is notified in PIE Assistant and can re-enable the heartbeat from the Automations page. ### API Endpoints Manage heartbeats programmatically through the tasks API: | Method | Endpoint | Description | |--------|----------|-------------| | `GET` | `/api/tasks` | List all heartbeats | | `POST` | `/api/tasks` | Create a new heartbeat | | `PATCH` | `/api/tasks/:id` | Update a heartbeat | | `DELETE` | `/api/tasks/:id` | Delete a heartbeat | | `POST` | `/api/tasks/:id/run` | Manually trigger a heartbeat | | `GET` | `/api/tasks/:id/runs` | View run history for a heartbeat | ## Next Steps * [Handling Secrets](/guides/handling-secrets) - Secure credential storage * [OAuth Basics](/guides/oauth-basics) - OAuth authentication * [Context API Reference](/reference/context-api) - Full API documentation --- --- url: /docs/getting-started/your-first-connector.md --- # Your First Connector Connectors are tools that integrate with OAuth services. They let PIE access your accounts (Gmail, Notion, etc.) securely. ## What Makes Connectors Special * **OAuth flow handled by PIE** - You don't write OAuth code * **Tokens never exposed** - Your code uses `context.oauth.fetch()` * **Auto-refresh** - PIE refreshes tokens automatically * **Secure storage** - Tokens encrypted with AES-256-GCM ## Example: Notion Connector Let's build a connector that searches your Notion workspace. ### Step 1: Create a Notion Integration 1. Go to [Notion Integrations](https://www.notion.so/my-integrations) 2. Click **New Integration** 3. Name it "PIE Connector" 4. Enable **Read content** capability 5. Save and copy the **OAuth client ID** and **OAuth client secret** 6. Add `https://your-pie-domain.com/api/oauth/plugin/{pluginId}/callback` as a redirect URI ### Step 2: Create the Agent **Tier**: Connector **Code**: ```js /** * Notion Connector - Search and read Notion pages * * Uses context.oauth.fetch() - PIE handles tokens securely. * This code NEVER sees the access token. */ const NOTION_API = 'https://api.notion.com/v1'; async function handler(input, context) { const { action, query } = input; // Check if OAuth is connected const isConnected = await context.oauth.isConnected(); if (!isConnected) { return { error: true, message: 'Notion not connected. Please connect in Agents.', requiresAuth: true }; } try { switch (action) { case 'search': { if (!query) { return { error: true, message: 'Search query is required' }; } // PIE automatically adds the Bearer token const response = await context.oauth.fetch( `${NOTION_API}/search`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Notion-Version': '2022-06-28', }, body: JSON.stringify({ query, page_size: 10, }), } ); if (!response.ok) { throw new Error(`Notion search failed: ${response.status}`); } const data = JSON.parse(response.body); // Format results for the AI const results = data.results.map(page => ({ id: page.id, title: page.properties?.title?.title?.[0]?.plain_text || 'Untitled', type: page.object, url: page.url, })); return { success: true, action: 'search', query, count: results.length, results }; } default: return { error: true, message: `Unknown action: ${action}. Valid actions: search` }; } } catch (error) { return { error: true, message: error.message || 'Notion operation failed' }; } } module.exports = { handler }; ``` **Manifest**: ```json { "trigger": "auto", "oauth": { "provider": "notion", "scopes": [], "clientIdSecret": "NOTION_CLIENT_ID", "clientSecretSecret": "NOTION_CLIENT_SECRET" }, "developerSecrets": { "NOTION_CLIENT_ID": { "description": "Notion OAuth Client ID", "required": true }, "NOTION_CLIENT_SECRET": { "description": "Notion OAuth Client Secret", "required": true } }, "tool": { "name": "notion", "description": "Search and read Notion pages. Use when the user asks about their Notion workspace.", "parameters": { "type": "object", "properties": { "action": { "type": "string", "enum": ["search"], "description": "The Notion action to perform." }, "query": { "type": "string", "description": "For search: text to search for in Notion." } }, "required": ["action"] } } } ``` > **Note:** Notion is a built-in provider, but you can also configure it as a custom provider if needed. See the [Custom Providers](#custom-providers) section below for an example of custom OAuth configuration. ### Step 3: Add OAuth Credentials After creating the agent, add your Notion OAuth credentials as developer secrets. ## OAuth Manifest Schema ### Built-in Providers For Google, GitHub, Slack, Notion - use the provider name: ```json { "oauth": { "provider": "google", "scopes": ["https://www.googleapis.com/auth/gmail.readonly"], "clientIdSecret": "GOOGLE_CLIENT_ID", "clientSecretSecret": "GOOGLE_CLIENT_SECRET" } } ``` ### Custom Providers For services without a built-in provider, use `"provider": "custom"`: ```json { "oauth": { "provider": "custom", "providerName": "Dropbox", "authorizationUrl": "https://www.dropbox.com/oauth2/authorize", "tokenUrl": "https://api.dropboxapi.com/oauth2/token", "scopes": ["files.metadata.read", "files.content.read"], "clientIdSecret": "DROPBOX_CLIENT_ID", "clientSecretSecret": "DROPBOX_CLIENT_SECRET" } } ``` ## The OAuth Context API | Method | Description | |--------|-------------| | `context.oauth.isConnected()` | Check if user has connected | | `context.oauth.fetch(url, options)` | Make authenticated request | | `context.oauth.getConnectionInfo()` | Get email, provider info | ### context.oauth.fetch() This is the key method. It works like regular fetch but PIE automatically: 1. Adds the `Authorization: Bearer {token}` header 2. Refreshes the token if expired 3. Handles errors gracefully ```js const response = await context.oauth.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, headers: object } ``` ## Security: Why Tokens Are Never Exposed Your agent code runs in an **isolated sandbox** (E2B sandbox or isolated-vm). When you call `context.oauth.fetch()`: 1. Your code sends the request details to PIE 2. PIE (outside the sandbox) adds the OAuth token 3. PIE makes the actual HTTP request 4. PIE returns the response to your sandbox The token exists only in PIE's trusted code, never in your agent. ## Next Steps * [OAuth Providers Reference](/reference/oauth-providers) - All supported providers * [Gmail Connector Example](/examples/gmail-connector) - Full featured connector * [Error Handling Guide](/guides/error-handling) - Handle OAuth errors gracefully --- --- url: /docs/getting-started/your-first-public-app.md --- # Your First Public App Public apps let your plugin serve a real web page at a shareable URL. In this tutorial, you will build a simple public RSVP app with: * a launchable widget for the plugin owner * a public page for visitors * public actions that power the public page * a managed database for storing settings and responses This is the same high-level pattern used by `pie-forms`: one authenticated builder surface, one shareable public surface, and a small set of public actions in between. ## What You Are Building The final flow looks like this: 1. The plugin owner opens a widget inside PIE 2. The widget lets them edit a public title and description 3. The widget shows a share link 4. Visitors open that share link and submit their RSVP 5. The plugin stores those responses in its managed database ## Architecture | Piece | Purpose | |-------|---------| | `===widget===` | Owner-facing editor inside PIE | | `===public-file===` | Visitor-facing public page | | `input._widgetAction` | Saves builder changes from the widget | | `input._publicAction` | Handles anonymous browser requests | | `context.managedDb.query()` | Stores settings and RSVPs | | `context.publicApp` | Gives the widget a share URL and lets public actions respond to the browser | ## Step 1: Create the Plugin Skeleton Start with a `.pie` file like this: ```yaml --- name: public-rsvp displayName: Public RSVP description: Build and host a simple public RSVP page. tier: tool version: 1.0.0 manifest: trigger: auto widget: launchable: true defaultHeight: 420 maxHeight: 700 database: tables: event_settings: 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 default 'Community RSVP'" description: "text default 'Tell us if you are coming.'" created_at: "timestamptz default now()" rls: "pie_user_id" event_responses: columns: id: "uuid primary key default gen_random_uuid()" pie_user_id: "text not null default current_setting('app.user_id', true)" event_id: "uuid not null" name: "text not null" email: "text not null" submitted_at: "timestamptz default now()" rls: "pie_user_id" tool: name: public_rsvp description: Open and manage a public RSVP page. parameters: type: object properties: action: type: string enum: [show] description: Open the RSVP builder widget required: [action] heartbeatEvents: events: - id: rsvp_submitted displayName: RSVP submitted description: Fires when a visitor submits the public RSVP form. enabled: true matchers: - source: public actionId: submit_rsvp - id: rsvp_settings_updated displayName: RSVP settings updated description: Fires when the owner saves changes from the builder widget. enabled: true matchers: - source: widget actionId: save_event --- ``` ### Why this schema? * `event_settings` stores the public-facing copy the visitor sees * `event_responses` stores each submitted RSVP * `description` is public-facing text, not an internal note ## Step 2: Add the Handler Logic Next, add the server-side code section below the frontmatter: ```js async function getOrCreateEvent(context) { var existing = await context.managedDb.query( 'SELECT id, title, description FROM event_settings ORDER BY created_at ASC LIMIT 1' ); if (existing.rows && existing.rows[0]) { return existing.rows[0]; } var created = await context.managedDb.query( 'INSERT INTO event_settings (title, description) VALUES ($1, $2) RETURNING id, title, description', ['Community RSVP', 'Tell us if you are coming.'] ); return created.rows[0]; } function buildShareUrl(context, eventId) { if (!context.publicApp) return null; return ( context.publicApp.baseUrl + '/apps/' + context.publicApp.pluginSlug + '/' + context.publicApp.instanceSlug + '/?eventId=' + eventId ); } async function showBuilder(context, flash) { var event = await getOrCreateEvent(context); await context.widget.show({ event: 'builder', settings: event, shareUrl: buildShareUrl(context, event.id), flash: flash || null, }); } async function handler(input, context) { if (input._publicAction) { var payload = input.payload || {}; switch (input.actionId) { case 'load_event': { if (!payload.eventId) { context.publicApp.respond({ error: true, message: 'eventId is required' }); return { error: true }; } var eventResult = await context.managedDb.query( 'SELECT id, title, description FROM event_settings WHERE id = $1 LIMIT 1', [payload.eventId] ); if (!eventResult.rows || !eventResult.rows[0]) { context.publicApp.respond({ error: true, message: 'Event not found' }); return { error: true }; } context.publicApp.respond({ event: eventResult.rows[0] }); return { success: true }; } case 'submit_rsvp': { if (!payload.eventId || !payload.name || !payload.email) { context.publicApp.respond({ error: true, message: 'eventId, name, and email are required' }); return { error: true }; } await context.managedDb.query( 'INSERT INTO event_responses (event_id, name, email) VALUES ($1, $2, $3)', [payload.eventId, payload.name, payload.email] ); context.publicApp.respond({ success: true, message: 'Your RSVP has been recorded.', }); return { success: true }; } default: context.publicApp.respond({ error: true, message: 'Unknown public action' }); return { error: true }; } } if (input._widgetAction) { var payload = input.payload || {}; switch (input.actionId) { case '_launch': { await showBuilder(context); return { success: true }; } case 'save_event': { await context.managedDb.query( 'UPDATE event_settings SET title = $1, description = $2 WHERE id = $3', [payload.title || 'Community RSVP', payload.description || '', payload.eventId] ); await showBuilder(context, 'Saved'); return { success: true }; } default: return { error: true, message: 'Unknown widget action' }; } } switch (input.action) { case 'show': { await context.widget.show({ event: 'loading' }); return { success: true, message: 'RSVP builder opened.' }; } default: return { error: true, message: 'Unknown action' }; } } module.exports = { handler }; ``` ### What this handler does * `show` opens the widget * `_launch` loads the current settings into the widget * `save_event` saves the public-facing title and description * `load_event` serves public page data * `submit_rsvp` stores visitor responses Those last two mutations are also exposed as curated Heartbeat events because we declared them in `manifest.heartbeatEvents`. ## Step 3: Add the Widget Now add a small owner-facing widget. This is where the plugin owner edits the public page. ```html ===widget===
===widget=== ``` ### Important note The `description` field here is the public-facing copy shown to visitors. Treat it like landing-page text, not an internal admin note. ## Step 4: Add the Public App Now add the public files that visitors will load in their browser. ```yaml ===public-config=== entry: index.html routes: - path: / spa: true ===public-config=== ``` Then add the public HTML: ```html ===public-file:index.html=== RSVP

Loading...

===public-file:index.html=== ``` And the public JavaScript: ```js ===public-file:app.js=== function getInstanceId() { var parts = window.location.pathname.replace(/\/+$/, '').split('/').filter(Boolean); return parts.length >= 3 ? parts[2] : ''; } function getEventId() { return new URLSearchParams(window.location.search).get('eventId') || ''; } async function callPublicAction(actionId, payload) { var instanceId = getInstanceId(); var response = await fetch('/api/public-actions/' + encodeURIComponent(instanceId) + '/' + actionId, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload || {}), }); var result = await response.json(); if (!response.ok || result.error) { throw new Error(result.message || 'Request failed'); } return result.data; } async function loadEvent() { var data = await callPublicAction('load_event', { eventId: getEventId(), }); document.getElementById('title').textContent = data.event.title || 'RSVP'; document.getElementById('description').textContent = data.event.description || ''; document.title = data.event.title || 'RSVP'; } async function submitRsvp() { var button = document.getElementById('submit'); var message = document.getElementById('message'); button.disabled = true; button.textContent = 'Submitting...'; message.textContent = ''; try { var data = await callPublicAction('submit_rsvp', { eventId: getEventId(), name: document.getElementById('name').value, email: document.getElementById('email').value, }); message.textContent = data.message || 'Thanks for your RSVP.'; document.getElementById('name').value = ''; document.getElementById('email').value = ''; } catch (error) { message.textContent = error.message || 'Something went wrong.'; } finally { button.disabled = false; button.textContent = 'Submit RSVP'; } } document.getElementById('submit').addEventListener('click', submitRsvp); loadEvent().catch(function(error) { document.getElementById('message').textContent = error.message || 'Could not load event.'; }); ===public-file:app.js=== ``` ### Why use `./app.js` instead of `/app.js`? Public apps live under `/apps/{pluginSlug}/{instanceSlug}/...`, so relative asset paths are the safest default for your bundled files. ## Step 5: Save and Test Once you save or import the plugin: 1. Open the widget by asking your assistant to run the tool with `show` 2. Edit the public title and description 3. Copy the share link from the widget 4. Open that link in a private browser window 5. Submit a test RSVP ## What to Build Next Once this basic version works, you can expand it in the same direction as `pie-forms`: * multiple public forms instead of one settings row * themes and richer public UI * file uploads via the public upload route * validation, thank-you pages, and analytics * list and export responses through tool actions ## When to Use This Pattern Use this exact split when your plugin needs: * an in-app builder or admin experience for the owner * a public page for visitors * plugin-owned storage and database records * shareable URLs without deploying a separate frontend For lower-level details on routing, packaging, uploads, and domains, continue to the [Public Apps and Routes guide](/guides/public-apps). --- --- url: /docs/getting-started/your-first-skill.md --- # Your First Skill Skills are prompt-based agents that shape how PIE responds. They come in two modes: * **Always-on skills** are injected into every conversation (personality, formatting, tone) * **On-demand skills (prompt tools)** are invoked by the AI only when relevant (domain expertise, analysis) ## What Skills Do * Add personality traits (always-on) * Set response formatting rules (always-on) * Provide domain expertise on demand (prompt tool) * Give specialized analysis capabilities (prompt tool) *** ## Always-On Skills Always-on skills modify *how* PIE responds. They're lightweight prompts included in every conversation. ### Create an Always-On Skill 1. Go to **Agents** in PIE 2. Click **Create Agent** 3. Select **Skill** tier 4. Fill in the details: **Name**: `friendly-assistant`\ **Display Name**: Friendly Assistant\ **Description**: Makes PIE respond in a warm, friendly manner **Prompt** (the body of your skill): ```text You are a friendly and warm assistant. Always: - Start responses with a friendly greeting when appropriate - Use conversational language - Show enthusiasm when helping - Offer encouragement and positive feedback - End with a helpful follow-up question when relevant Keep your personality consistent but natural - don't be over-the-top. ``` **Manifest**: ```json { "trigger": "always" } ``` ### How Always-On Skills Work 1. The skill's prompt is added to PIE's system prompt 2. It's included in every conversation 3. Multiple skills combine together *** ## On-Demand Skills (Prompt Tools) On-demand skills provide domain expertise that the AI invokes only when relevant. Instead of burning tokens on every message, the knowledge is delivered as a tool result when the AI decides it's needed. This is ideal for: * Legal document analysis (NDAs, contracts) * Code review checklists * Compliance frameworks * Industry-specific expertise * Any knowledge that's deep but not always relevant ### How Prompt Tools Work 1. User asks: "Can you review this NDA for me?" 2. AI sees the `analyze_nda` tool in its available tools 3. AI calls the tool with the document text 4. PIE returns the skill's prompt (expert knowledge) as the tool result 5. AI uses that expertise to formulate its analysis No code runs. No sandbox. The prompt template IS the tool result. ### Create an On-Demand Skill The `.pie` file format is the same as always-on skills, but the manifest includes a `tool:` definition: ```yaml --- name: nda-analyzer displayName: NDA Analyzer description: Expert NDA analysis with risk assessment and recommendations tier: skill version: 1.0.0 manifest: trigger: auto tool: name: analyze_nda description: >- Analyze a Non-Disclosure Agreement for risks, unusual clauses, red flags, and missing protections. Call when a user asks to review an NDA. parameters: type: object properties: document_text: type: string description: The full text of the NDA to analyze focus_areas: type: string description: "Optional: specific concerns like 'non-compete scope'" required: - document_text --- You are an expert contract attorney specializing in NDAs. When analyzing: ## Structure your analysis as: 1. **Agreement Type**: Mutual vs one-way, identify the parties 2. **Key Terms**: Duration, scope, exclusions 3. **Risk Assessment**: Rate overall risk as Low / Medium / High 4. **Red Flags**: Non-standard or unusually broad clauses 5. **Missing Protections**: Standard clauses that are absent 6. **Recommendations**: Specific suggested modifications ## Pay special attention to: - Overly broad definitions of "confidential information" - Non-compete or non-solicitation clauses hidden in NDAs - Asymmetric obligations - Jurisdiction and governing law implications ``` The key differences from an always-on skill: | | Always-On Skill | On-Demand Skill (Prompt Tool) | |---|---|---| | **Manifest** | `{ "trigger": "always" }` | Has a `tool:` definition with name, description, parameters | | **When active** | Every conversation | Only when the AI decides to call it | | **Token cost** | Paid on every message | Only when invoked | | **Best for** | Personality, tone, formatting | Domain expertise, analysis, knowledge bases | | **Body content** | Short behavioral prompt | Can be very large (knowledge bases, frameworks) | ### When to Use Which **Use always-on** when the skill is: * A personality or tone modifier * A formatting preference * Short (under ~500 tokens) * Relevant to virtually every conversation **Use on-demand** when the skill is: * Domain-specific expertise * A large knowledge base * Only relevant to certain topics * Something with clear input parameters (a document to analyze, a topic to research) ## Tips for Good Skills 1. **Be specific** - Clear instructions work better than vague ones 2. **Don't conflict** - Avoid always-on skills that contradict each other 3. **Keep always-on skills lightweight** - Save large knowledge bases for on-demand 4. **Write good tool descriptions** - For on-demand skills, the description tells the AI *when* to invoke it 5. **Define clear parameters** - Help the AI understand what inputs your skill needs ## Next Steps Now that you've created a skill, let's build something more powerful: [Your First Tool](/getting-started/your-first-tool). --- --- url: /docs/getting-started/your-first-tool.md --- # Your First Tool Tools let PIE take actions during conversations. They're JavaScript functions that run in a secure sandbox. ## What Tools Do * Call external APIs * Process and transform data * Perform calculations * Interact with services ## Create a Tool Let's build a weather tool that fetches current weather data. ### Step 1: Get an API Key 1. Sign up at [WeatherAPI.com](https://www.weatherapi.com/) 2. Copy your API key ### Step 2: Create the Agent 1. Go to **Agents** in PIE 2. Click **Create Agent** 3. Select **Tool** tier ### Step 3: Write the Code ```js /** * Weather Tool - Get current weather for any city */ async function handler(input, context) { const { city } = input; if (!city) { return { error: true, message: 'City is required' }; } // Get API key from secrets const apiKey = context.secrets.WEATHER_API_KEY; if (!apiKey) { return { error: true, message: 'Weather API key not configured' }; } // Make the API request using context.fetch() const response = await context.fetch( `https://api.weatherapi.com/v1/current.json?key=${apiKey}&q=${encodeURIComponent(city)}` ); if (!response.ok) { return { error: true, message: `Failed to fetch weather: ${response.status}` }; } // Parse the response const data = JSON.parse(response.body); // Return structured data for the AI return { city: data.location.name, country: data.location.country, temperature_c: data.current.temp_c, temperature_f: data.current.temp_f, condition: data.current.condition.text, humidity: data.current.humidity, wind_kph: data.current.wind_kph, feels_like_c: data.current.feelslike_c, }; } module.exports = { handler }; ``` ### Step 4: Define the Manifest ```json { "trigger": "auto", "developerSecrets": { "WEATHER_API_KEY": { "description": "API key from weatherapi.com", "required": true } }, "tool": { "name": "get_weather", "description": "Get current weather conditions for a city. Use when users ask about weather.", "parameters": { "type": "object", "properties": { "city": { "type": "string", "description": "City name (e.g., 'London', 'New York', 'Tokyo')" } }, "required": ["city"] } } } ``` ### Step 5: Add Your API Key 1. After creating the agent, go to agent settings 2. Add your `WEATHER_API_KEY` ## Expose Heartbeat Events When It Makes Sense Not every tool needs event triggers. Read-only tools like this weather example usually **do not** need `heartbeatEvents`. If your tool performs meaningful mutations, publishes content, sends messages, or creates records, add a curated event catalog to the manifest so users can build Heartbeats from those actions: ```json { "heartbeatEvents": { "events": [ { "id": "report_created", "displayName": "Report created", "description": "Fires when the tool creates a new report.", "enabled": true, "matchers": [ { "source": "tool", "action": "create_report" } ] } ] } } ``` PIE can suggest these events automatically when you save the plugin, but you should review the suggestions and keep only the user-facing outcomes you want to expose. ## How Tools Work 1. User asks: "What's the weather in Tokyo?" 2. AI sees the `get_weather` tool description 3. AI decides to call the tool with `{ city: "Tokyo" }` 4. PIE runs your handler in a sandbox 5. Your code calls the weather API 6. Results return to the AI 7. AI formats a response for the user ## Understanding the Handler ```js async function handler(input, context) { // input = parameters from the AI (e.g., { city: "Tokyo" }) // context = PIE's API // - context.fetch(url, options) - make HTTP requests // - context.secrets - your API keys // - context.user - user information return { /* data for the AI */ }; } module.exports = { handler }; ``` ## The Context Object | Property | Type | Description | |----------|------|-------------| | `context.fetch(url, options)` | Function | Make HTTP requests | | `context.secrets` | Object | Developer secrets (API keys) | | `context.user.id` | String | Current user's ID | | `context.user.displayName` | String | User's display name | | `context.userConfig` | Object | User-defined configuration values for this agent | | `context.userConfig` | Object | User-configurable settings (from `userFields` in manifest) | | `context.oauth.*` | Object | OAuth operations: `isConnected()`, `fetch()`, `getConnectionInfo()` | | `context.files.*` | Object | File storage: `upload()`, `list()`, `get()`, `delete()` | | `context.db.query()` | Function | Run read-only SQL queries against external Postgres databases | | `context.tasks.*` | Object | Task scheduling: `create()`, `list()`, `update()`, `delete()` | See the full [Context API reference](/reference/context-api) for details on all available properties and methods. ## Security Scanning When you create or update a tool, PIE automatically runs a security scan on your code. The scan checks for common security issues like data exfiltration, credential harvesting, and code injection. Your tool must pass all 10 security criteria to receive a "Verified" badge. You can view scan results and manually trigger re-scans from the **Security** tab in the Developer Portal. See the full [Security Scanning guide](/guides/security-scanning) for details. ## Tips for Good Tools 1. **Clear descriptions** - Help the AI know when to use your tool 2. **Validate input** - Check for required parameters 3. **Handle errors** - Return helpful error messages 4. **Return structured data** - Let the AI format the response ## Next Steps Need a shareable frontend or public route for your tool? Read the [Public Apps and Routes guide](/guides/public-apps). Ready for the advanced stuff? Let's [build an OAuth connector](/getting-started/your-first-connector).