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:
- Ask for their portal URL (e.g.
https://portal.example.com). - Have them mint an admin API token at
<portal-url>/admin/tokens(it's shown once — they copy it). - Give them the command to run:
claude mcp add --transport http portal <portal-url>/mcp \ --header "Authorization: Bearer <token>" - Once connected, call
whoamito confirm (expectrole: admin). Upload with theupload_apptool — base64 the packaged.zip; passreplace=trueto 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:
- A running portal reachable at some URL.
- An API token from
<portal-url>/admin/tokens(shown once — save it). - Config at
~/.config/pwa-portal/config.json. Fastest:
Or setpython3 ~/.claude/skills/pwa-portal-app/scripts/configure.pyPORTAL_URL/PORTAL_TOKENenv 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 manifestentry)- App's own CSS/JS/icons/images
icon.png(required) — referenced fromportal.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, framesd.rounded_rectangle(...)— modern rounded panelsd.ellipse(...)— circles, dots, clock faces, gaugesd.polygon([(x, y), ...], fill=..., outline=...)— triangles, arrows, custom shapesd.line([(x0, y0), (x1, y1)], fill=..., width=N)— clock hands, divider lines, axesd.arc(box, start, end, fill=..., width=N)— gauges, progress ringsd.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
iconmust be a relative path inside the bundle (no.., no leading/). - The file must exist in the zip.
package.pyrejects 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;
localhostis 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.
- Declare the tile origin in
permissions.network(it's now afetch, soconnect-srcmust allow it). fetch(tileUrl)→res.blob()→URL.createObjectURL(blob)→ set that as the<img>'ssrc. 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. UnderCHILD_APPS_SAME_ORIGIN=truethe 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-argsetTimeoutare blocked. Prefer external.jsfiles 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}.typeisstring|number|boolean, orarrayfor a list of objects — an array param addsfields: [{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 asdata:URIs.branded: trueprepends the portal header.deliver.kind:share(→{url}),download(→ base64 PDF),store(→ saves atkey), oremail(→ sends the HTML totowithsubject).to/subject/keymay use{{ param }}.- A tool may only use services you also list in
services—share/downloadneedpdf,storeneedspdf+storage,emailneedsemail— 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.
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
- Understand requirements. Ask the user 1–2 clarifying questions max. Default to something simple and shippable. Don't over-engineer.
- Pick a slug. Kebab-case, short and descriptive (
receipt-emailer,quote-calc,contact-form). - Scaffold. Copy the basic template:
Then editcp -r ~/.claude/skills/pwa-portal-app/templates/basic /path/to/<slug>portal.json(slug/name/version) andindex.html(initial content). - Implement. Edit
index.html. Add CSS/JS inline or as separate files. Keep it minimal — non-coder users don't want elaborate code. - 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.pyrefuses to build a zip whose icon is still the scaffold placeholder, so the step is enforced, not optional. - Package.
Producespython3 ~/.claude/skills/pwa-portal-app/scripts/package.py /path/to/<slug><slug>-<version>.zipnext to the source folder. - Upload. If connected via the portal's MCP server (Option A), call the
upload_apptool — base64-encode the.zipintozip_base64, passreplace=trueto update an existing app. Otherwise use the script:
On success printspython3 ~/.claude/skills/pwa-portal-app/scripts/upload.py /path/to/<slug>-<version>.zipUploaded <name> (slug: ..., version: ...). - 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>inindex.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 asdefer). - 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
--accentvariable (emerald by default) viacolor-mix(), in both light and dark mode. The basic scaffold attemplates/basic/index.htmlalready 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-gloware computed from--accentwithcolor-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 (--liftflips between black and white, and the*-amtpercentages 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">withmax-width: 36remfor forms / 60rem for wide layouts; padding2rem 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), padding0.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.025emfor 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/--successfor "good",--danger-tint/--dangerfor "bad",--warn-tint/--warnfor caution,--accent-tint/--accentfor 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-mdis 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:
- Bump
versioninportal.json. - Repackage:
python3 ~/.claude/skills/pwa-portal-app/scripts/package.py /path/to/<slug> - Upload with
--replace:
On success printspython3 ~/.claude/skills/pwa-portal-app/scripts/upload.py --replace /path/to/<slug>-<version>.zipReplaced <name> (slug: ..., version: ...). Per-user data underdata/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.pyprints an HTTP error, the message body is the portal's validation feedback. Adjustportal.jsonor the bundle accordingly. - Storage is per
(app_slug, user_id)— Alice's data inexpense-trackeris invisible to Bob inexpense-trackerand to Alice inquote-calc. - App pages are gated by portal login (you don't add auth — the portal handles it) — EXCEPT public intake forms. A declared
formspage 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), useforms; 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."