Skip to content

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. This guide is the lower-level reference for packaging, routing, uploads, and domains.

Widgets vs Public Apps

SurfaceAudienceURLClient APIServer API
WidgetAuthenticated PIE userInside the PIE appPIE.onData(), PIE.sendAction(), PIE.resize(), PIE.onTheme()context.widget
Public appAnyone with the link/apps/{pluginSlug}/{instanceSlug}/... or a custom domainStandard 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===
<!DOCTYPE html>
<html>
<body>
  <h1>Public Demo</h1>
  <script type="module" src="./app.js"></script>
</body>
</html>
===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

FieldTypeDescription
entrystringEntry HTML file to serve for the root URL. Usually index.html.
routesarrayOptional route rules for SPA fallback behavior.
routes[].pathstringRoute prefix that should resolve to the entry file when no static file matches.
routes[].spabooleanIf 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
<script>
async function loadForm(instanceId, formId) {
  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();
  if (!response.ok || result.error) {
    throw new Error(result.message || 'Request failed');
  }

  return result.data;
}
</script>

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

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.

Built with VitePress