pwa-portal-app

star 1

Build, package, and upload a small PWA app for a self-hosted PWA Portal. Use when the user wants to create or modify a tool/app for their portal — e.g. "make me a receipt app for my portal", "build a quote calculator", "I need a contact form app". Handles scaffolding, implementation, packaging, and upload via the portal API.

jacob-scheatzle By jacob-scheatzle schedule Updated 6/12/2026

name: pwa-portal-app description: Build, package, and upload a small PWA app for a self-hosted PWA Portal. Use when the user wants to create or modify a tool/app for their portal — e.g. "make me a receipt app for my portal", "build a quote calculator", "I need a contact form app". Handles scaffolding, implementation, packaging, and upload via the portal API.

PWA Portal App Builder

This skill builds child PWAs ("apps") that live inside a self-hosted PWA Portal. The portal hosts multiple small-business apps under one origin; each app is a folder of HTML/CSS/JS plus a portal.json manifest, packaged as a .zip and uploaded.

The portal provides shared services that apps call from JavaScript via a built-in SDK: PDF generation, email, per-user/per-app storage, and user info.

Apps can also declare public, no-sign-in intake forms that the portal serves to people without an account — the one part of an app reachable without login. This is the built-in way to collect input from the public (a quote request, a lead/contact form, a survey); see Public intake forms below. (So "can a stranger fill out a form on the portal?" is yes — don't conclude otherwise or reach for an outside form service.)

When to use this skill

Use when the user asks you to:

  • Create a new app for their portal ("make me a [X] app for my portal", "build a quote tool", "I need a contact form")
  • Modify or extend an app you previously built (re-package and re-upload)
  • Package or upload an existing app folder

Don't use if the user is asking about the portal itself (admin UI, settings, auth) — that's the host application, not a child app you're building.

First-time setup: connect to a portal

You upload finished apps to a running PWA Portal. There are two ways to connect — pick whichever the user has set up.

Option A — MCP connector (recommended)

If the portal has its MCP server (on by default in the Docker image; an admin can disable it with MCP_ENABLED=false — see the portal's docs/mcp.md), you manage apps as tool calls with no config file. First check whether a portal MCP connection is already available (look for whoami / list_apps / upload_app tools). If it is, use it — and confirm with whoami that the token is an admin.

If it's NOT connected yet, walk the user through it conversationally — don't guess at URLs:

  1. Ask for their portal URL (e.g. https://portal.example.com).
  2. Have them mint an admin API token at <portal-url>/admin/tokens (it's shown once — they copy it).
  3. Give them the command to run:
    claude mcp add --transport http portal <portal-url>/mcp \
      --header "Authorization: Bearer <token>"
    
  4. Once connected, call whoami to confirm (expect role: admin). Upload with the upload_app tool — base64 the packaged .zip; pass replace=true to update an existing app in place (preserves per-user storage).

Option B — upload script (portal without the MCP server)

If the portal doesn't run the MCP server, use the bundled scripts + a saved config. The user needs:

  1. A running portal reachable at some URL.
  2. An API token from <portal-url>/admin/tokens (shown once — save it).
  3. Config at ~/.config/pwa-portal/config.json. Fastest:
    python3 ~/.claude/skills/pwa-portal-app/scripts/configure.py
    
    Or set PORTAL_URL / PORTAL_TOKEN env vars for one-off use.

If you're on Option B and config is missing, ask the user for their portal URL and token and offer to write the config for them (or run configure.py) before continuing — don't guess at URLs.

App structure

Every app is a folder containing:

  • portal.json — manifest (required, at the root of the zip)
  • index.html — entry page (default; override with manifest entry)
  • App's own CSS/JS/icons/images
  • icon.png (required) — referenced from portal.json.icon, drives the dashboard tile and the iOS home-screen icon. See "Icons" below — every app you build must ship a real one, not the scaffold placeholder.

When uploaded, the portal extracts to data/apps/<slug>/ and serves the app.

Icons (required for every app)

Every app MUST ship a real icon. The dashboard tile and the iOS "Add-to-Home-Screen" badge both pull from portal.json.icon; an app with no icon falls back to the generic portal placeholder, which makes the home screen useless after the user installs more than one app.

Make a pictogram, not a letter. A user with six or seven apps on their home screen reads icons by shape, not by initial. Six letter tiles all in the same accent green look identical at a glance. A speedometer for mileage, a receipt for expenses, a clock for time tracking — each is recognizable in a tenth of a second.

The scaffold ships a placeholder at templates/basic/icon.png. You must replace it before packaging — package.py refuses to build a zip whose icon byte-for-byte matches the placeholder.

Drawing a pictogram with Pillow

Pillow is stdlib-adjacent (one pip install, no other dependencies) and can compose simple shapes — rectangles, circles, polygons, lines, arcs — on a solid background. That's all a recognizable business-app icon needs. Aim for 192×192 PNG with the portal accent (#059669) as the background and a single white silhouette.

A working template — replace the # --- pictogram --- body with shapes that represent your app:

from PIL import Image, ImageDraw
W = 192
ACCENT = "#059669"  # portal default emerald; pick another hex if it fits the app better
im = Image.new("RGB", (W, W), ACCENT)
d = ImageDraw.Draw(im)

# --- pictogram --- (replace this with shapes that match the app)
# Example: a simple receipt outline for an expense logger
margin = 36
d.rounded_rectangle([margin, margin - 12, W - margin, W - margin + 12], radius=8, outline="white", width=6)
for y in (72, 96, 120):
    d.line([margin + 14, y, W - margin - 14, y], fill="white", width=4)
# zig-zag bottom edge typical of receipts
zigzag = [(margin, W - margin + 12)]
step = 12
x = margin
while x < W - margin:
    x += step
    zigzag.append((x, W - margin))
    x += step
    zigzag.append((x, W - margin + 12))
d.polygon(zigzag + [(W - margin, W - margin + 12)], fill=ACCENT, outline="white")

im.save("/path/to/<slug>/icon.png")

Pillow primitives you'll reach for most:

  • d.rectangle([x0, y0, x1, y1], fill=..., outline=..., width=N) — bars, panels, frames
  • d.rounded_rectangle(...) — modern rounded panels
  • d.ellipse(...) — circles, dots, clock faces, gauges
  • d.polygon([(x, y), ...], fill=..., outline=...) — triangles, arrows, custom shapes
  • d.line([(x0, y0), (x1, y1)], fill=..., width=N) — clock hands, divider lines, axes
  • d.arc(box, start, end, fill=..., width=N) — gauges, progress rings
  • d.pieslice(box, start, end, fill=..., outline=...) — pie wedges, hour markers

Pictogram recipes for common app categories:

App type Pictogram approach
Receipt / expense logger Rounded rect "paper" with horizontal lines for text and a zig-zag bottom edge
Invoice / quote builder Document outline + a $ or check-mark in the lower-right corner
Time tracker / clock-in Circle (clock face) + two lines for hour and minute hands
Mileage / odometer Circle outline + arc for the gauge sweep + a single line for the needle
Customer directory / CRM Rounded rect + circle (head) + half-circle (shoulders) — contact card silhouette
Calendar / scheduler Rounded rect with a thicker top band and a 3×3 grid of dots inside
Inventory / items 3–4 stacked rectangles, slightly offset to suggest stacked boxes
Forms / surveys Document outline + a sequence of small filled circles down the left side (radio buttons)
Photo / image gallery Rounded rect frame + two diagonal lines forming a mountain + a circle for the sun
Window / property cleaning Rounded square frame split into 4 quadrants by a + (window pane)

If you genuinely can't think of a pictogram (purely abstract apps, miscellaneous utilities), fall back to a centered letter — but only as a last resort. Same Pillow recipe as the pictogram, just with d.text((W/2, W/2), "X", fill="white", font=f, anchor="mm") instead of shapes.

User-supplied icons: if the user attached their own PNG / SVG / JPEG / WebP, skip the Pillow step and save it to <slug>/icon.png. Keep it square — 192×192 PNG is the sweet spot for both the dashboard tile and iOS home-screen icons.

What the portal actually validates:

  • Manifest's icon must be a relative path inside the bundle (no .., no leading /).
  • The file must exist in the zip.
  • package.py rejects the unmodified scaffold placeholder.

The portal does not enforce the icon's image format — it checks path-safety + existence only. (A future build may add an icon-extension allowlist; if your release rejects an icon on format grounds, that's why.) Recommended formats: PNG, SVG, JPEG, or WebP — PNG at 192×192 renders cleanest across iOS, Android, and desktop browsers. Ship a real raster/vector image even though the server won't catch a bogus one.

Authoring rule: never package.py an app whose icon is still the scaffold default. Draw the pictogram as part of the build workflow below — not as an afterthought.

Where the app runs

By default each app runs on its own subdomain — <slug>.apps.<SITE_URL> — inside an iframe wrapper on the portal. This isolates apps from each other and from the portal via the browser's same-origin policy. The change is transparent to you as an app author: the SDK at /portal-sdk.js handles cross-origin authentication automatically via a single-use launch token, and the existing <script src="/portal-sdk.js"></script> HTML pattern keeps working because the SDK is served same-origin from the subdomain at <slug>.apps.<SITE_URL>/portal-sdk.js.

Self-hosters who can't set up wildcard DNS can opt into a legacy same-origin mode (CHILD_APPS_SAME_ORIGIN=true), in which case the app runs at <SITE_URL>/apps/<slug>/. Either way, the SDK and your app's code are identical.

portal.json schema

{
  "slug": "my-app",
  "name": "My App",
  "version": "0.1.0",
  "description": "A short description.",
  "icon": "icon.png",
  "entry": "index.html",
  "services": ["pdf", "email"],
  "permissions": {
    "network": ["https://api.open-meteo.com"]
  },
  "min_portal_version": "0.1"
}
Field Required Notes
slug yes kebab-case, 2–40 chars, lowercase a-z 0-9 -, no leading/trailing hyphen
name yes 1–60 chars, human-readable
version yes freeform string up to 20 chars (semver recommended)
description no up to 200 chars
icon yes relative path inside the bundle, 192×192 PNG / SVG / JPEG / WebP. See "Icons" above — placeholder is rejected at package time.
entry no defaults to index.html; must exist in the zip
services yes if calling declarative list of portal services the app will use; allowed: pdf, email, storage. Enforced server-side — see "Services" below
permissions.network no external HTTPS origins the app's fetch() calls need to reach — see "Network permissions" below
permissions.csp_strict no when true, opt into a strict CSP that drops 'unsafe-inline'/'unsafe-eval' — see "Strict CSP" below
min_portal_version no hint for compatibility
tools no declarative operations an MCP-connected Claude can run server-side — see "Tools (let Claude run your app)" below
forms no public, no-sign-in intake forms on the app's own origin (<slug>.apps.<SITE_URL>/forms/<form>); submissions show under Admin → Submissions — see "Public intake forms" below

The slug becomes the URL: an app with slug expense-tracker is reachable at /apps/expense-tracker/.

Services

services is the list of portal services your app calls. The portal enforces this server-side: if your app calls portal.email.send() without "email" in services, the call returns 403 and the SDK throws.

Declare every service you use:

{ "services": ["pdf", "email", "storage"] }

On upload, every declared service is auto-approved (the admin uploaded the bundle). The admin can later revoke any service per-app under /admin/apps → expand "Services (.../...)" on that app's row. Revocations persist across re-uploads of the same slug — an updated bundle can't silently re-enable a service an admin turned off.

Back-compat: an app that declares NO services at all is treated as legacy and not gated. The moment you add even one entry, the gate activates and only the declared + admin-approved subset is callable.

Authoring rule: list every service your index.html touches. If you add a portal.pdf call later, bump the manifest first.

Network permissions

Child apps run under a strict Content-Security-Policy that only allows same-origin fetch() / XMLHttpRequest by default. If your app calls any external HTTP API (a weather service, a geocoder, a public dataset, etc.), you must declare each origin in permissions.network — the browser will block the request otherwise with a CSP violation, and the user will see a "Failed to fetch" / network error.

Rules:

  • Each entry is an HTTPS origin: https://host[:port]. No path, no query string, no wildcards.
  • Hostname must be a real DNS name; localhost is accepted in dev but pointless in production.
  • Up to 12 entries per manifest. If you genuinely need more, you're probably reaching for the wrong abstraction — front everything through one upstream.
  • HTTP (plain) is rejected. The portal is HTTPS-only; mixed content would fail at the browser anyway.

On upload, every declared origin is auto-approved (the admin uploaded the bundle, which counts as approval). The admin can later revoke or extend the list per-app under /admin/apps → expand "Network (...)" on that app's row. Revocations made through the admin UI persist across re-uploads of the same slug, so an updated bundle can't silently re-grant network access an admin previously turned off.

Authoring rule: every time you write a fetch("https://...") call into a child app, add the origin to permissions.network. The scaffold at templates/basic/portal.json ships with an empty list; populate it before packaging if the app calls anything external.

External API failure UX

External calls fail for boring reasons — the API is down, rate-limited, the network drops, or (most common in this portal) you forgot to declare the origin in permissions.network so the browser blocks the request with a CSP violation that surfaces as a generic TypeError: Failed to fetch. Never let an app render blank or hang on a failed external call. Wrap every external fetch in try/catch, and on failure show a short inline message in --text-muted / --danger that tells the user what to do next ("Couldn't reach the weather service — try again in a minute"), not a raw stack trace. Keep the rest of the app usable: a failed lookup should degrade to manual entry, not a dead screen. If the cause might be a missing declaration, the fix is in the manifest (permissions.network), not the code.

try {
  const r = await fetch("https://api.example.com/x");
  if (!r.ok) throw new Error(`HTTP ${r.status}`);
  render(await r.json());
} catch (err) {
  status.textContent = "Couldn't reach the service — try again shortly.";
  // (a CSP-blocked request lands here too — check permissions.network)
}

Map tiles: declare the origin, then fetch-to-blob

Map/basemap tiles are external <img> requests, and the child-app CSP's img-src does not include arbitrary remote hosts — so a bare <img src="https://tiles…/{z}/{x}/{y}.png"> is blocked, even with the origin in permissions.network (that gates connect-src / fetch, not img-src). The workaround: fetch() each tile and swap in a blob: URL, which img-src does allow.

  1. Declare the tile origin in permissions.network (it's now a fetch, so connect-src must allow it).
  2. fetch(tileUrl)res.blob()URL.createObjectURL(blob) → set that as the <img>'s src. Revoke the object URL when the tile scrolls out.

Use a CORS-enabled, no-key tile provider (the fetch is cross-origin, so the response needs Access-Control-Allow-Origin):

Provider Origin (declare in permissions.network) Tile URL shape
Carto basemaps https://basemaps.cartocdn.com …/light_all/{z}/{x}/{y}.png
Esri (ArcGIS) https://server.arcgisonline.com …/tile/{z}/{y}/{x} (note {z}/{y}/{x})
OpenStreetMap https://tile.openstreetmap.org …/{z}/{x}/{y}.png
async function loadTile(imgEl, url) {
  const res = await fetch(url);              // origin declared in permissions.network
  const blobUrl = URL.createObjectURL(await res.blob());
  imgEl.onload = () => URL.revokeObjectURL(blobUrl);
  imgEl.src = blobUrl;                       // blob: is allowed by img-src
}

Strict CSP (opt-in)

By default the portal allows 'unsafe-inline' and 'unsafe-eval' so existing apps that ship inline <script> blocks keep working. Apps that want a stronger guarantee can opt into a strict Content-Security-Policy:

{
  "permissions": {
    "csp_strict": true
  }
}

Under strict CSP, every inline <script> and <style> must carry a nonce attribute that matches the per-response nonce the portal injects. Use the literal token {{NONCE}} in your HTML — the portal substitutes the real value at serve time:

<script nonce="{{NONCE}}">
  // legitimate inline init
</script>
<style nonce="{{NONCE}}">
  body { background: #fafaf9; }
</style>

Rules:

  • Only the app subdomain mode honors csp_strict. Under CHILD_APPS_SAME_ORIGIN=true the portal launcher and child app share an origin; the launcher needs its own inline scripts so the flag is silently ignored. If you ship apps for self-hosters who may run in same-origin mode, design HTML that works under both CSPs.
  • eval(), new Function(), and string-arg setTimeout are blocked. Prefer external .js files for non-trivial logic; the nonce is for small init blocks, not whole apps.
  • Imported stylesheets (<link rel="stylesheet">) and scripts (<script src="...">) work without nonces — they're same-origin.

When to enable it: customer-facing apps where you want a hard guarantee an HTML-injection bug can't pivot to script execution. Skip it for internal tools where the friction outweighs the benefit.

Tools (let Claude run your app)

If the portal runs the MCP server (see its docs/mcp.md), an app can declare tools an MCP-connected Claude calls directly — e.g. "create a quote for Acme at $1,250 and share it." A tool is declarative (no code): the portal renders an HTML template you provide to a PDF, then shares / downloads / emails / stores it. Uploaded app code never runs server-side.

{
  "services": ["pdf"],
  "tools": [
    {
      "name": "create_quote",
      "description": "Render a quote PDF and return a shareable link.",
      "params": [
        {"name": "customer", "type": "string", "required": true, "description": "Customer name"},
        {"name": "amount", "type": "number", "required": true}
      ],
      "render": { "html": "<h1>Quote for {{ customer }}</h1><p>Total: ${{ amount }}</p>", "filename": "quote.pdf", "branded": true },
      "deliver": { "kind": "share", "ttl_days": 30 }
    }
  ]
}
  • name: snake_case, unique; Claude sees it as <slug>__<name>.
  • params[]: each {name, type, required, description}. type is string | number | boolean, or array for a list of objects — an array param adds fields: [{name, type, required, description}] describing each element (see "Line items" below).
  • render.html: a sandboxed, autoescaping Jinja template rendered to PDF. {{ param }} values are escaped; {% for %}, {% if %}, arithmetic, {{ '%.2f'|format(n) }}, namespace(...) totals, and {{ x | default(0, true) }} all work. No external fetches — embed images/fonts as data: URIs. branded: true prepends the portal header.
  • deliver.kind: share (→ {url}), download (→ base64 PDF), store (→ saves at key), or email (→ sends the HTML to to with subject). to / subject / key may use {{ param }}.
  • A tool may only use services you also list in servicesshare/download need pdf, store needs pdf + storage, email needs email — the upload is rejected otherwise, and an admin can revoke the capability per-app.

Authoring rules: keep templates self-contained (inline CSS); template storage keys from IDs, not free-text names (spaces/punctuation are rejected as storage keys).

Line items (array params)

For invoices, quotes, work orders — anything with a variable list — use an array param and loop it, accumulating a total with a namespace:

{
  "name": "create_invoice",
  "description": "Render an itemized invoice PDF and return a share link.",
  "params": [
    {"name": "customer", "type": "string", "required": true},
    {"name": "items", "type": "array", "required": true,
     "fields": [
       {"name": "description", "type": "string", "required": true},
       {"name": "qty", "type": "number", "required": true},
       {"name": "rate", "type": "number", "required": true}
     ]}
  ],
  "render": { "html": "<table>{% set ns = namespace(t=0) %}{% for it in items %}<tr><td>{{ it.description }}</td><td>${{ '%.2f'|format(it.qty * it.rate) }}</td></tr>{% set ns.t = ns.t + it.qty * it.rate %}{% endfor %}</table><p>Total: ${{ '%.2f'|format(ns.t) }}</p>", "branded": true },
  "deliver": { "kind": "share", "ttl_days": 30 }
}

Full, working versions ship in examples/invoice-gen, examples/quote-builder, and examples/work-order.

Public intake forms (let non-users submit data)

This is THE way to make something on the portal that a person without an account can open and fill out. When the user asks for a public / external / no-login / customer-facing / "shareable" form — a quote request, a lead or contact form, a survey — even if they phrase it as "a form shared via a link", the answer is forms. Specifically:

  • Don't conclude it's impossible because "apps are gated by login" — forms are the explicit public exception.
  • Don't confuse it with /s/ share links — those serve a read-only file or once-rendered PDF, never a live form a stranger fills out.
  • Don't reach for an outside service (Google Forms, Apps Script, Typeform, etc.) — public intake is built into the portal.

Declare forms and the portal serves each one as a public, no-sign-in page at <slug>.apps.<SITE_URL>/forms/<form>that public URL is the link you share. Submissions collect under Admin → Submissions (CSV export) and in the data export. No app code runs server-side; the portal renders the form from your field list.

"forms": [
  {
    "name": "quote_request",
    "title": "Request a quote",
    "description": "Tell us what you need.",
    "fields": [
      { "name": "full_name", "label": "Your name", "type": "text", "required": true, "placeholder": "Jane Doe" },
      { "name": "email", "label": "Email", "type": "email", "required": true },
      { "name": "details", "label": "Details", "type": "textarea" }
    ],
    "notify_email": "owner@example.com",
    "success_message": "Thanks — we'll be in touch."
  }
]

Field type is text | email | tel | number | textarea. Each field also takes an optional placeholder (≤120 chars) — hint text shown in the empty input. Forms need no services. The public endpoint is rate-limited per IP and has a spam honeypot.

The form URL is what you share. <slug>.apps.<SITE_URL>/forms/<form> (shown under Admin → Submissions) is a public link — the owner puts it on a website, emails it, or texts it to customers. When you build a form app, surface this URL to the user so they know what to hand out.

Pair forms with share links (see "Share links" below) to close the loop: a form collects a request; a share link sends a document back. After a request arrives, render the result to a PDF and reply with a public, expiring, revocable /s/<token> link — portal.share.create({ kind: "pdf", … }) from app JS, or deliver: { kind: "share" } on a declarative tool. A typical intake app ships both: the quote_request form and a quote-builder that returns a share link.

Scheduling: any tool can run on a recurring schedule (daily/weekly/monthly) from Admin → Schedules, or via Claude over MCP (create_schedule) — handy for "email the monthly report" with nobody clicking a button. Schedules aren't part of the manifest; they're set up after the app is uploaded.

Portal SDK — how apps call services

In your app's HTML, include the SDK before your own scripts:

<script src="/portal-sdk.js"></script>

This exposes window.portal with these methods. All calls use the signed-in user's session automatically — apps don't manage auth.

User info

const me = await portal.user.current();
// { id: 3, email: "owner@example.com", role: "admin" }

PDF generation (server-side, via WeasyPrint)

// Trigger a browser download:
await portal.pdf.download({
  html: "<h1>Receipt</h1><p>Total: $42</p>",
  filename: "receipt.pdf",
});

// Or get a Blob to attach/upload/render yourself:
const blob = await portal.pdf.render({ html: "...", filename: "..." });

// Opt-in: prepend the portal's branding header (business name + logo + accent
// border). Pulls from /admin/settings → Branding. Pass branded: true on any
// PDF where the document represents the business — quotes, invoices,
// receipts, statements:
await portal.pdf.download({
  html: "<html><body><h1>Quote</h1>...</body></html>",
  filename: "quote.pdf",
  branded: true,
});

The HTML you pass is rendered server-side. You can include <style> blocks and inline CSS. External resources (images, fonts) are blocked by a strict URL fetcher and must be embedded as data: URIs.

When to set branded: true: customer-facing documents (quotes, invoices, receipts, statements, work orders). Skip it for internal-only reports where the extra header would just waste space.

Email

await portal.email.send({
  to: "customer@example.com",        // single email OR array of emails
  subject: "Your receipt",
  html: "<p>Hi there.</p>",
  text: "Hi there.",                 // include at least one of html/text
});

Returns { status: "sent", count: N } on success. Throws if SMTP isn't configured on the portal (503) or send fails.

Storage (per-app, per-user namespace)

// Put — value can be a Blob, string, or any JSON-serializable value
await portal.storage.put("notes/today.json", { entries: ["a", "b"] });
await portal.storage.put("receipt.pdf", pdfBlob);

// Get — auto-detects type from content type
const notes = await portal.storage.get("notes/today.json");   // parsed JSON
const blob = await portal.storage.get("receipt.pdf");          // Blob

await portal.storage.list();
// { items: [{ key, size }], usage: <bytes>, limit: 104857600 }

await portal.storage.delete("notes/today.json");

Keys allow A-Z a-z 0-9 . _ - and / (forward slash acts as folder separator). 10MB per object, 100MB total per namespace.

Share links (public, tokenized URLs)

For sending a document to a customer who isn't a portal user — quotes, invoices, signed contracts, anything where "make them sign up" would be friction:

// Kind 1: share something already in storage.
const shareA = await portal.share.create({
  kind: "storage",
  key: "receipts/123.pdf",       // must exist in this user's storage
  filename: "receipt.pdf",       // shown on download
  ttlSeconds: 7 * 24 * 3600,     // 7 days default; 90d cap
  maxViews: 0,                   // 0 = unlimited; cap at 10000
});
// → { token, url: "https://<site>/s/<token>", expires_at, kind, max_views }

// Kind 2: render a fresh PDF at share-create time.
const shareB = await portal.share.create({
  kind: "pdf",
  html: "<html><body><h1>Quote #42</h1>...</body></html>",
  filename: "quote-42.pdf",
  ttlSeconds: 30 * 24 * 3600,
  maxViews: 3,
});

Hand shareA.url to your user — email it, paste it into Messages, etc. Anyone with the link can open it until it expires, hits its view cap, or the admin revokes from /admin/shares.

Storage shares stream the file live, so editing the stored object updates what recipients see. PDF shares are rendered once and frozen.

Requires the corresponding service in your manifest's services (storage for kind=storage, pdf for kind=pdf).

Share links are the natural other half of a public intake form (above): the form collects a request from someone who isn't signed in, and a share link hands a document back to them. If you build an app with an intake form, consider whether the workflow ends in a document the owner sends back — if so, add a share-link step (portal.share.create(...) or a deliver: { kind: "share" } tool) so the app covers the whole round trip, not just collection.

Build workflow

  1. Understand requirements. Ask the user 1–2 clarifying questions max. Default to something simple and shippable. Don't over-engineer.
  2. Pick a slug. Kebab-case, short and descriptive (receipt-emailer, quote-calc, contact-form).
  3. Scaffold. Copy the basic template:
    cp -r ~/.claude/skills/pwa-portal-app/templates/basic /path/to/<slug>
    
    Then edit portal.json (slug/name/version) and index.html (initial content).
  4. Implement. Edit index.html. Add CSS/JS inline or as separate files. Keep it minimal — non-coder users don't want elaborate code.
  5. Draw a pictogram icon — REQUIRED, see the "Icons" section above. Pick a shape from the recipe table that fits the app (receipt, clock, gauge, contact card, etc.) and draw it with Pillow primitives on the portal accent background. Save to <slug>/icon.png. A letter on a colored square is the last-resort fallback, not the default — pictograms are how a user with five apps on their home screen tells them apart. package.py refuses to build a zip whose icon is still the scaffold placeholder, so the step is enforced, not optional.
  6. Package.
    python3 ~/.claude/skills/pwa-portal-app/scripts/package.py /path/to/<slug>
    
    Produces <slug>-<version>.zip next to the source folder.
  7. Upload. If connected via the portal's MCP server (Option A), call the upload_app tool — base64-encode the .zip into zip_base64, pass replace=true to update an existing app. Otherwise use the script:
    python3 ~/.claude/skills/pwa-portal-app/scripts/upload.py /path/to/<slug>-<version>.zip
    
    On success prints Uploaded <name> (slug: ..., version: ...).
  8. Confirm. Tell the user the app is live at <portal_url>/apps/<slug>/.

Coding conventions for child apps

  • HTML/CSS/JS only. No build step, no npm, no bundler. The zip ships as-is.
  • Single-file layout is fine for small apps — inline <style> and <script> in index.html. Only split into separate files when it helps readability.
  • Load the SDK first: <script src="/portal-sdk.js"></script> in <head>, your own scripts after it (or as defer).
  • Don't ship a service worker. The portal's SW handles the origin.
  • No external CSS frameworks unless the user specifically asks. Lean styling is fine.
  • Persistent data goes through portal.storage. Never hit external APIs for user data without asking.
  • Be defensive about user input — validate emails, numbers, etc. before sending to the portal API.
  • Match the portal's visual style. The portal has a stone-neutral design system with a derived accent family: the hover/tint/soft/glow shades are all computed from a single --accent variable (emerald by default) via color-mix(), in both light and dark mode. The basic scaffold at templates/basic/index.html already includes the design tokens (CSS variables). When generating new HTML for a child app, start from the scaffold's <style> block and use the tokens below — apps then look consistent with the portal chrome and adapt to light/dark automatically.

Visual style — design tokens

Child apps should embed these CSS variables at the top of their <style> block. Light + dark are both defined; the browser picks based on prefers-color-scheme.

:root {
  color-scheme: light dark;
  --bg: #fafaf9;
  --surface: #ffffff;
  --surface-2: #f5f5f4;
  --border: #e7e5e4;
  --border-strong: #d6d3d1;
  --text: #1c1917;
  --text-muted: #57534e;
  --text-faint: #78716c;
  /* Accent family is DERIVED from --accent — change one variable to
     re-skin the app. --lift is what hover mixes toward (black in light,
     white in dark); the *-amt percentages strengthen tints in dark mode. */
  --accent: #059669;
  --accent-fg: #ffffff;
  --lift: #000000;
  --tint-amt: 9%;
  --soft-amt: 14%;
  --accent-hover: color-mix(in srgb, var(--accent) 85%, var(--lift));
  --accent-tint: color-mix(in srgb, var(--accent) var(--tint-amt), var(--surface));
  --accent-soft: color-mix(in srgb, var(--accent) var(--soft-amt), transparent);
  --accent-glow: color-mix(in srgb, var(--accent) 28%, transparent);
  --danger: #b91c1c;
  --danger-tint: #fef2f2;
  --warn: #b45309;
  --warn-tint: #fef3c7;
  --success: #15803d;
  --success-tint: #ecfdf5;
  --radius-sm: 0.5rem;
  --radius: 0.7rem;
  --radius-lg: 1rem;
  --shadow-sm: 0 1px 2px rgba(28,25,23,0.06), 0 1px 1px rgba(28,25,23,0.03);
  --shadow: 0 2px 6px rgba(28,25,23,0.07), 0 1px 2px rgba(28,25,23,0.05);
  --shadow-md: 0 10px 24px -6px rgba(28,25,23,0.12), 0 3px 8px rgba(28,25,23,0.06);
  --font-sans: -apple-system, BlinkMacSystemFont, "Inter", "Segoe UI",
    Roboto, "Helvetica Neue", Arial, sans-serif;
  --font-mono: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
}
@media (prefers-color-scheme: dark) {
  :root {
    --bg: #0c0a09;
    --surface: #1c1917;
    --surface-2: #292524;
    --border: #292524;
    --border-strong: #44403c;
    --text: #fafaf9;
    --text-muted: #a8a29e;
    --text-faint: #78716c;
    --accent: #10b981;
    --accent-fg: #0c0a09;
    --lift: #ffffff;
    --tint-amt: 18%;
    --soft-amt: 22%;
    --danger: #f87171;
    --danger-tint: #450a0a;
    --warn: #fbbf24;
    --warn-tint: #451a03;
    --success: #4ade80;
    --success-tint: #052e16;
    --shadow-sm: 0 1px 2px rgba(0,0,0,0.4), 0 1px 1px rgba(0,0,0,0.25);
    --shadow: 0 2px 6px rgba(0,0,0,0.5), 0 1px 2px rgba(0,0,0,0.3);
    --shadow-md: 0 10px 24px -6px rgba(0,0,0,0.6), 0 3px 8px rgba(0,0,0,0.35);
  }
}

Usage conventions:

  • Derived accent family. --accent-hover, --accent-tint, --accent-soft, and --accent-glow are computed from --accent with color-mix() — never hard-code their values. To re-brand an app, change ONLY --accent — hover/tint/soft/glow derive from it automatically, in light and dark mode alike (--lift flips between black and white, and the *-amt percentages strengthen the tints in dark).
  • Body gets a soft accent wash bleeding down from the top, plus a selection tint:
    body {
      font-family: var(--font-sans);
      background:
        radial-gradient(70rem 30rem at 50% -14rem, var(--accent-soft), transparent 70%),
        var(--bg);
      color: var(--text);
      margin: 0;
      min-height: 100svh;
      line-height: 1.55;
      -webkit-font-smoothing: antialiased;
    }
    ::selection { background: var(--accent-soft); }
    
  • Wrap page content in <main class="shell"> with max-width: 36rem for forms / 60rem for wide layouts; padding 2rem 1.5rem 4rem.
  • Primary buttons are top-lit gradients over the accent with an accent-tinted glow shadow, and press down on :active:
    button {
      display: inline-flex; align-items: center; justify-content: center; gap: 0.4rem;
      padding: 0.6rem 1.1rem;
      border: 1px solid color-mix(in srgb, var(--accent) 80%, #000);
      border-radius: var(--radius-sm);
      background: linear-gradient(180deg, color-mix(in srgb, var(--accent) 88%, #fff), var(--accent));
      color: var(--accent-fg);
      font-weight: 600; font-size: 0.925rem; font-family: inherit; cursor: pointer;
      box-shadow: 0 1px 2.5px var(--accent-glow), inset 0 1px 0 color-mix(in srgb, #fff 28%, transparent);
      transition: background 0.12s ease, box-shadow 0.12s ease, transform 0.06s ease;
    }
    button:hover { background: linear-gradient(180deg, color-mix(in srgb, var(--accent) 78%, #fff), color-mix(in srgb, var(--accent) 94%, #fff)); }
    button:active { transform: translateY(1px); box-shadow: none; }
    
  • Secondary buttons: background: var(--surface), color: var(--accent), border: 1px solid var(--border-strong), no shadow; hover → background: var(--accent-tint) + border-color: var(--accent).
  • Inputs: background: var(--surface), border: 1px solid var(--border-strong), border-radius: var(--radius-sm), padding 0.65rem 0.85rem; focus → border-color: var(--accent) + box-shadow: 0 0 0 3px var(--accent-soft).
  • Keyboard focus gets an accent outline: a:focus-visible, button:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
  • Headings: font-weight: 700, letter-spacing: -0.025em for h1.
  • Cards / panels: background: var(--surface), border: 1px solid var(--border), border-radius: var(--radius-lg), padding: 1.5rem 1.6rem, box-shadow: var(--shadow-sm).
  • Pills / badges: padding: 0.15rem 0.625rem, border-radius: 9999px, font-size: 0.7rem, font-weight: 600, plus a hairline ring in the text color: box-shadow: inset 0 0 0 1px color-mix(in srgb, currentColor 30%, transparent). Use --success-tint / --success for "good", --danger-tint / --danger for "bad", --warn-tint / --warn for caution, --accent-tint / --accent for neutral-emphasized.
  • Radii are rounder than they used to be: --radius-sm: 0.5rem (buttons, inputs), --radius: 0.7rem, --radius-lg: 1rem (cards). Shadows are softer and larger; --shadow-md is for floating elements like menus and dialogs.
  • Status messages: small text in --text-muted; errors in --danger; success in --success.

If an app needs a one-off color (chart legend, brand callout), invent a CSS variable scoped to that element rather than dropping a hex literal mid-template — keeps the palette legible.

Minimal example: hello user

<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>Hello</title>
  <script src="/portal-sdk.js"></script>
  <style>
    /* (Paste the full :root token block from "Visual style" above) */
    :root {
      color-scheme: light dark;
      --bg: #fafaf9; --surface: #ffffff; --border: #e7e5e4;
      --text: #1c1917; --text-muted: #57534e;
      --accent: #059669; --accent-fg: #ffffff;
      --radius-sm: 0.5rem;
      --font-sans: -apple-system, BlinkMacSystemFont, "Inter", "Segoe UI", sans-serif;
    }
    @media (prefers-color-scheme: dark) {
      :root { --bg: #0c0a09; --surface: #1c1917; --border: #292524; --text: #fafaf9; --text-muted: #a8a29e; --accent: #10b981; --accent-fg: #0c0a09; }
    }
    * { box-sizing: border-box; }
    body { font-family: var(--font-sans); background: var(--bg); color: var(--text); margin: 0; }
    .shell { max-width: 32rem; margin: 0 auto; padding: 2rem 1.5rem; }
    h1 { font-weight: 700; letter-spacing: -0.025em; }
  </style>
</head>
<body>
  <main class="shell">
    <h1 id="greeting">Loading…</h1>
    <script>
      portal.user.current().then(me => {
        document.getElementById("greeting").textContent = `Hi, ${me.email}`;
      });
    </script>
  </main>
</body>
</html>

Example: receipt → PDF → email

<form id="form">
  <label>Customer email <input name="customer" type="email" required></label>
  <label>Amount <input name="amount" type="number" step="0.01" required></label>
  <button>Generate & send</button>
</form>
<p id="status"></p>
<script src="/portal-sdk.js"></script>
<script>
const f = document.getElementById("form");
const status = document.getElementById("status");
f.onsubmit = async (e) => {
  e.preventDefault();
  status.textContent = "Working…";
  const data = new FormData(f);
  const html = `<h1>Receipt</h1><p>Amount: $${data.get("amount")}</p>`;
  try {
    await portal.pdf.download({ html, filename: "receipt.pdf" });
    await portal.email.send({
      to: data.get("customer"),
      subject: "Your receipt",
      html,
    });
    status.textContent = "Sent.";
  } catch (err) {
    status.textContent = "Error: " + err.message;
  }
};
</script>

Re-uploading an updated app

To ship an update in place, preserving per-user storage:

  1. Bump version in portal.json.
  2. Repackage:
    python3 ~/.claude/skills/pwa-portal-app/scripts/package.py /path/to/<slug>
    
  3. Upload with --replace:
    python3 ~/.claude/skills/pwa-portal-app/scripts/upload.py --replace /path/to/<slug>-<version>.zip
    
    On success prints Replaced <name> (slug: ..., version: ...). Per-user data under data/storage/<slug>/<user_id>/* is left untouched.

Omitting --replace keeps the original behavior: the portal rejects an upload whose slug already exists. Use that for first-time installs, and --replace for updates. Avoid deleting and re-uploading — deletion wipes per-user storage.

Things to remember

  • One slug = one app. Pick wisely; it's the URL.
  • The skill is non-interactive. Don't expect to prompt during a build — gather inputs up front.
  • Read the portal's error. If upload.py prints an HTTP error, the message body is the portal's validation feedback. Adjust portal.json or the bundle accordingly.
  • Storage is per (app_slug, user_id) — Alice's data in expense-tracker is invisible to Bob in expense-tracker and to Alice in quote-calc.
  • App pages are gated by portal login (you don't add auth — the portal handles it) — EXCEPT public intake forms. A declared forms page is served with no sign-in at <slug>.apps.<SITE_URL>/forms/<form>, so a stranger can open and submit it. If the user wants a public / external / customer-facing form (even phrased as "share it via a link" — the form's URL is that link), use forms; don't conclude it's impossible because apps are gated, and never reach for an outside service (Google Forms, Apps Script, etc.). See "Public intake forms."
Install via CLI
npx skills add https://github.com/jacob-scheatzle/claude-pwa-portal --skill pwa-portal-app
Repository Details
star Stars 1
call_split Forks 0
navigation Branch main
article Path SKILL.md
More from Creator
jacob-scheatzle
jacob-scheatzle Explore all skills →