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:
- The plugin owner opens a widget inside PIE
- The widget lets them edit a public title and description
- The widget shows a share link
- Visitors open that share link and submit their RSVP
- 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:
---
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_settingsstores the public-facing copy the visitor seesevent_responsesstores each submitted RSVPdescriptionis public-facing text, not an internal note
Step 2: Add the Handler Logic
Next, add the server-side code section below the frontmatter:
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
showopens the widget_launchloads the current settings into the widgetsave_eventsaves the public-facing title and descriptionload_eventserves public page datasubmit_rsvpstores 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.
===widget===
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<style>
:root {
--bg: #FFFFFF;
--bg-secondary: #FAFAFA;
--fg: #171717;
--fg-secondary: #525252;
--accent: #FF0066;
--accent-soft: rgba(255, 0, 102, 0.08);
--border: #E5E5E5;
--radius: 10px;
--font: 'Inter', system-ui, sans-serif;
}
* { box-sizing: border-box; }
body {
margin: 0;
padding: 16px;
font-family: var(--font);
background: var(--bg);
color: var(--fg);
}
.field { margin-bottom: 14px; }
.label { font-size: 12px; color: var(--fg-secondary); margin-bottom: 6px; display: block; }
.input, .textarea {
width: 100%;
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 10px 12px;
font: inherit;
}
.textarea { min-height: 96px; resize: vertical; }
.share-box {
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 12px;
font-size: 12px;
word-break: break-all;
}
.btn {
border: none;
border-radius: var(--radius);
padding: 10px 14px;
background: var(--accent);
color: white;
cursor: pointer;
font: inherit;
font-weight: 600;
}
.flash {
margin-bottom: 12px;
padding: 10px 12px;
border-radius: var(--radius);
background: var(--accent-soft);
color: var(--accent);
font-size: 13px;
display: none;
}
</style>
</head>
<body>
<div id="flash" class="flash"></div>
<div class="field">
<label class="label">Public Title</label>
<input id="title" class="input" placeholder="Community RSVP">
</div>
<div class="field">
<label class="label">Public Description</label>
<textarea id="description" class="textarea" placeholder="Tell visitors what this page is for."></textarea>
</div>
<div class="field">
<label class="label">Share Link</label>
<div id="share" class="share-box">Generating link...</div>
</div>
<button class="btn" onclick="save()">Save</button>
<script>
var currentEvent = null;
PIE.onData(function(data) {
if (!data) return;
if (data.event === 'loading') {
PIE.sendAction('_launch', {});
return;
}
if (data.event === 'builder') {
currentEvent = data.settings || null;
document.getElementById('title').value = currentEvent ? (currentEvent.title || '') : '';
document.getElementById('description').value = currentEvent ? (currentEvent.description || '') : '';
document.getElementById('share').textContent = data.shareUrl || 'Save the plugin once to get a public link.';
var flash = document.getElementById('flash');
if (data.flash) {
flash.style.display = 'block';
flash.textContent = data.flash;
setTimeout(function() {
flash.style.display = 'none';
}, 1400);
}
}
PIE.resize(Math.min(Math.max(document.body.scrollHeight + 20, 260), 700));
});
function save() {
if (!currentEvent) return;
PIE.sendAction('save_event', {
eventId: currentEvent.id,
title: document.getElementById('title').value,
description: document.getElementById('description').value,
});
}
</script>
</body>
</html>
===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.
===public-config===
entry: index.html
routes:
- path: /
spa: true
===public-config===Then add the public HTML:
===public-file:index.html===
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>RSVP</title>
<style>
body {
margin: 0;
font-family: Inter, system-ui, sans-serif;
background: #FAFAFA;
color: #171717;
}
.wrap {
max-width: 560px;
margin: 48px auto;
background: white;
border: 1px solid #E5E5E5;
border-radius: 16px;
padding: 24px;
box-shadow: 0 8px 24px rgba(0,0,0,0.05);
}
h1 { margin: 0 0 8px; font-size: 32px; }
p { color: #525252; line-height: 1.6; }
.field { margin-top: 16px; }
label { display: block; margin-bottom: 6px; font-size: 13px; color: #525252; }
input {
width: 100%;
border: 1px solid #E5E5E5;
border-radius: 10px;
padding: 12px;
font: inherit;
box-sizing: border-box;
}
button {
margin-top: 16px;
border: none;
border-radius: 10px;
padding: 12px 16px;
background: #FF0066;
color: white;
font: inherit;
font-weight: 600;
cursor: pointer;
}
.message { margin-top: 14px; font-size: 14px; }
</style>
</head>
<body>
<div class="wrap">
<h1 id="title">Loading...</h1>
<p id="description"></p>
<div class="field">
<label>Name</label>
<input id="name" placeholder="Ada Lovelace">
</div>
<div class="field">
<label>Email</label>
<input id="email" type="email" placeholder="[email protected]">
</div>
<button id="submit">Submit RSVP</button>
<div id="message" class="message"></div>
</div>
<script type="module" src="./app.js"></script>
</body>
</html>
===public-file:index.html===And the public JavaScript:
===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:
- Open the widget by asking your assistant to run the tool with
show - Edit the public title and description
- Copy the share link from the widget
- Open that link in a private browser window
- 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.