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
| 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:
---
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:
entry: index.html
routes:
- path: /
spa: true
- path: admin
spa: trueFields
| 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:
routes:
- path: /
spa: trueThen 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:
/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/domainsPOST /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:
POST /api/public-actions/{instanceIdOrSlug}/{actionId}Browser example:
<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:
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:
{
"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:
{
_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:
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:
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:
context.publicApp
// {
// instanceId: '...',
// visitorIp: '...',
// respond(data) { ... }
// }Use context.publicApp.respond(data) to set the JSON payload returned to the browser:
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:
context.managedDb.query()still works, but it runs against the plugin owner's managed database identity.context.user.idin a public action is the plugin owner's user ID, not the anonymous visitor.- 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:
POST /api/public-actions/{instanceIdOrSlug}/upload
Content-Type: multipart/form-dataBrowser example:
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
urlis 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:
- PIE parses the
public-configandpublic-filesections - PIE uploads the bundle to storage
- PIE creates or updates the active public app deployment
- PIE creates or reuses a public app instance for the plugin owner
- PIE returns a
publicAppUrlin 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:
- Widget for the authenticated builder/editor used by the plugin owner
- Public app for the shareable page visitors see
- Public actions for anonymous reads/writes from that page
- Managed DB for plugin-owned data
- 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.