Skip to content

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

PiecePurpose
===widget===Owner-facing editor inside PIE
===public-file===Visitor-facing public page
input._widgetActionSaves builder changes from the widget
input._publicActionHandles anonymous browser requests
context.managedDb.query()Stores settings and RSVPs
context.publicAppGives 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===
<!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.

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

Then add the public HTML:

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:

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.

Built with VitePress