---
slug: 41b-pwa
title: Your app on the phone (PWA)
description: Make a Synsema site installable on Android, iOS and desktop — manifest, service worker, icons, honest offline — and notify users with native Web Push (push_send / push_vapid_keys), all from one `synsema init --pwa`.
example_ids: [pwa]
---

# Your app on the phone (PWA)

A Synsema site **installs** on a phone or a desktop as an app: an icon on the home screen, full screen without the browser chrome, a shell that opens without network, and push notifications — with nothing but the web the server already renders. No native client, no app store, no framework. The client is the browser the user already has; the backend, the agents and the secrets stay on the server, where they belong.

```sh
synsema init --pwa myapp && cd myapp
synsema serve app.syn            # http://localhost:8080 — Chrome and Edge install it from localhost
```

Press **Run** for the doctested part — the VAPID pair is born sealed, the private key is only accepted as a secret, and delivering is refused before any socket opens unless you declared the push service:

```synsema
-- Doc example: Web Push (installable apps). The VAPID pair is born SEALED, the private
-- key is accepted only as a secret, and delivering is refused before any socket opens
-- unless the push service's host was declared (deny-by-default). Runs anywhere: no
-- network is touched.
intent: "doc example: web push — VAPID keys + the gates"
require random                     -- push_vapid_keys() creates new secret material

let keys be push_vapid_keys()      -- {public: text, private: secret}
print("public key: " + text(length(keys["public"])) + " chars · private: " + text(keys["private"]))

task subscription()
    -- the shape the browser gives you: PushSubscription.toJSON() (keys from RFC 8291 here)
    give {"endpoint": "https://web.push.apple.com/QAbc123", "keys": {"p256dh": "BP4z9KsN6nGRTbVYI_c7VJSPQTBtkgcy27mlmlMoZIIgDll6e3vCYLocInmYWAmS6TlzAC8wEqKK6PBru3jl7A8", "auth": "BTBZMqHH6r4Tts7J_aSIgg"}}

task send_without_net()
    -- no `require net("web.push.apple.com")` → refused at the capability check
    give push_send(subscription(), {"title": "hi"}, {"vapid": {"public": keys["public"], "private": keys["private"], "subject": "mailto:ops@example.com"}})

task send_with_a_plain_text_key()
    -- a private key as plain text is never accepted (doctrine: private keys are secrets)
    give push_send(subscription(), "hi", {"vapid": {"public": keys["public"], "private": "not-a-secret", "subject": "mailto:ops@example.com"}})

test "the pair: a base64url public key and a SEALED private key"
    assert_eq(length(keys["public"]), 87)                        -- 65-byte uncompressed P-256 point
    assert_eq(text(keys["private"]), "secret(vapid_private)")    -- redacted wherever it goes

test "delivering needs net(<push service host>) — refused before any socket opens"
    assert_error(send_without_net)

test "the VAPID private key is only accepted as a secret"
    assert_error(send_with_a_plain_text_key)
```

## What `synsema init --pwa` writes (engine v0.6.15+)

| File | What it is |
|---|---|
| `app.syn` | the server: `static "/" from "./public"`, the page, and `mount api.api` (engine v0.6.19+; before that the routes were inline) |
| `api.syn` | (engine v0.6.19+) the API as an `export routes api` group — `/api/ping` and the push routes (`/api/push/config`, `/api/push/subscribe` at 10/min, `/api/push/test` at 2/min), so the same routes serve the [desktop entry](/en/0.6.x/41c-desktop) too. A module carries no `require`: the entry declares the capabilities |
| `push_keys.syn` | one-off: prints `VAPID_PUBLIC_KEY` / `VAPID_PRIVATE_KEY` / `VAPID_SUBJECT` for `.env` |
| `index.html` | the page — `<link rel="manifest">`, `theme-color`, `apple-touch-icon`, full-screen metas, `<script src="/app.js">` |
| `public/manifest.webmanifest` | `id: "/"`, name, `start_url: "/"`, `scope: "/"`, `display: "standalone"`, icons 192 + 512 (`any`) and a separate 512 `maskable` (Lighthouse rejects a combined `any maskable`). Add `screenshots` (and a longer `description`) for the richer install sheet and the stores — they are yours to take, so `init` doesn't invent them |
| `public/sw.js` | the service worker: shell cached **stale-while-revalidate** (serves from cache, refreshes behind — a change shows on the *next* open; bump `CACHE` to force it now), `/api/` never cached, push → notification with **badge** + `tag`/`renotify`, tap → **focuses the open window** (and tells it via `postMessage`) instead of opening a fresh one |
| `public/app.js` | registers the worker, the **Install** button (`beforeinstallprompt`), the iPhone hint, an honest status (`navigator.onLine` **and** a `no-store` probe of `/api/ping` — a down server is not "online"), **Notify me** (waits for the *activated* worker, reuses an existing subscription, retries the first `subscribe` with backoff, shows `name: message` on failure) and the demo **Send a test push** |
| `public/icon.svg`, `public/icon-maskable.svg`, `public/badge.svg` | the source icons: `icon-192.png` / `icon-512.png` / `apple-touch-icon.png` from the first, `icon-maskable-512.png` (full-bleed background, art in the safe 80%) from the second, `badge-96.png` (the monochrome status-bar icon Android needs — without it you get a generic one) from the third. All **generated** by `init`: edit an SVG, re-run `init --pwa`, its PNGs follow; the others are left alone |

Everything else `init` writes (`.env.example`, `.gitignore`, `.mcp.json`) comes too; `hello.syn` does not — the starter is `app.syn`. Re-running is safe: a file you edited is kept and the factory version lands beside it as `<file>.new`. `--pwa` and `--synfide` are different starters — pick one (exit 2 with both).

## The pieces, so you can add them to an existing app

1. **Mount `public/` at the root** — the service worker must be served from `/sw.js` to control the whole origin:

```synsema
require serve(8080)
require file.read("index.html")            -- render() reads the page from disk (engine v0.6.14+)

serve on 8080
    static "/" from "./public" cache "1h"
    route "GET /"
        give render("index.html", {"title": "My app"})
```

   `.webmanifest` is served as `application/manifest+json` (pinned — the browser rejects the manifest under any other type). Declared routes win over static, so `/` stays yours.

2. **The `<head>`** of your layout needs the scaffold's lines: `<link rel="manifest" href="/manifest.webmanifest">`, `<meta name="theme-color">`, `<link rel="apple-touch-icon" href="/apple-touch-icon.png">`, `<meta name="mobile-web-app-capable" content="yes">` and its `apple-` twin (iOS won't go full screen or use your icon without them), `viewport-fit=cover`.

3. **HTTPS** in production: `synsema serve app.syn --domain app.example.com --tls-auto you@example.com` ([Deploy](/en/0.6.x/71-deploy)). `localhost` and `127.0.0.1` count as secure, so development needs nothing. iOS requires a **trusted** certificate for the service worker and the install.

4. **Push** is optional — the app works without keys; the page just says push is not configured.

## Where it installs, honestly

| | Android (Chrome, Edge, Samsung) | iPhone / iPad (Safari 16.4+) | Desktop (Edge, Chrome) |
|---|---|---|---|
| Install | the **Install** button (`beforeinstallprompt`) or the browser menu | *Share → Add to Home Screen* (no API; the page shows the hint) | menu → Install: own window, Start-menu / Dock entry |
| Icon, full screen | manifest | `apple-touch-icon` + the `apple-` metas | manifest |
| Offline shell | service worker | service worker | service worker |
| Push | yes | **only from the installed app**, permission on a tap | yes (Windows delivers through `*.notify.windows.com`) |
| Origin | HTTPS or localhost | HTTPS with a trusted certificate | HTTPS or localhost |
| Store | optional: Trusted Web Activity via PWABuilder — outside Synsema | optional: PWABuilder → Xcode project, case by case — outside Synsema | — |

Firefox on desktop has no install/app mode: the site works as a site.

## Offline that tells the truth

The scaffold's `sw.js` caches the **shell** (`/`, `/app.js`, the manifest, the icon) and leaves the API on the network: without connectivity `/api/*` answers `503 {"error": "offline"}` and the page says so. It never caches a non-OK response, so a failure is never replayed as data. If your `/` shows the logged-in user's data, take it out of the shell list and cache a public *"you're offline"* page instead — the cache belongs to the browser, not to a user.

Two things the scaffold decides for you, and why: the shell is **stale-while-revalidate** (the cached copy answers at once, the network refreshes it for the next open — so a change to `app.js` or `/` appears one open late; bump `CACHE` in `sw.js` when you need it now, the `activate` step drops the old cache). And a tapped notification **focuses the window that is already open** and tells it (`postMessage({type: "notificationclick", url})`) instead of spawning a second, stateless instance; only when there is none does it open one. If the OS killed the app, that new window starts from zero — anything that must survive (a draft, a filter, the current view) belongs in `localStorage`/IndexedDB, not in variables.

## Push, end to end

**1. Keys, once.** `synsema run push_keys.syn` prints the three lines for `.env`. The pair comes from `push_vapid_keys()` (needs `require random`: it creates secret material). The private key is born **sealed** — it prints redacted, so the script declares `require reveal("vapid_private")` and reveals it on purpose (audited). Same formats as `web-push generate-vapid-keys`.

**2. Subscribe.** `app.js` reads the public key from `GET /api/push/config`, asks permission, calls `pushManager.subscribe({userVisibleOnly: true, applicationServerKey})` and POSTs `PushSubscription.toJSON()` — `{"endpoint", "keys": {"p256dh", "auth"}}` — to `POST /api/push/subscribe`. Keep it in a table, tied to the logged-in user (`requires auth`); the scaffold keeps up to 500 in `state_*` as a demo.

**3. Send.** One line per push service you serve — deny-by-default, the push service is a host like any other:

```synsema
require secret("VAPID_PRIVATE_KEY")
require env("VAPID_PUBLIC_KEY")
require net("fcm.googleapis.com")                  -- Chrome, Android, Brave, Opera …
require net("jmt17.google.com")                    -- … Chrome hands out either FCM domain
require net("*.notify.windows.com")                -- Edge on Windows
require net("updates.push.services.mozilla.com")   -- Firefox
require net("web.push.apple.com")                  -- Safari, iPhone, iPad, Mac

let vapid be {"public": env("VAPID_PUBLIC_KEY"), "private": secret("VAPID_PRIVATE_KEY"), "subject": "mailto:ops@example.com"}
let r be push_send(sub, {"title": "Order shipped", "body": "#1042 is on its way", "url": "/orders/1042"},
    {"vapid": vapid, "ttl": 3600, "urgency": "high", "topic": "order-1042"})
when r["gone"]                                     -- 404 / 410: the browser unsubscribed
    sql_exec("DELETE FROM push_subs WHERE endpoint = ?", [sub["endpoint"]])
```

`push_send(subscription, payload, opts)` → `{status, ok, gone, retry_after, body}`:

- `payload`: text as-is · a map/list → JSON (what `sw.js` reads as `{title, body, url}`) · `bytes` · `nothing` for a body-less "something changed". **At most 3993 bytes** — the encrypted body must stay under the services' 4096. A `secret` payload is an error; a secret inside a map travels redacted.
- `opts.vapid` (required): `public` (base64url text), `private` (**a `secret` only** — from `secret("VAPID_PRIVATE_KEY")`, `as_secret(...)` or `push_vapid_keys()`; a plain string is refused, exactly like the private key of `sign`: whoever holds it can push to every user), `subject` (`mailto:` or `https://`). A public key that doesn't match the private one fails here, not as an opaque 401 from the service.
- `opts.ttl` seconds the service keeps an undelivered message (default 86400) · `opts.urgency` `very-low | low | normal | high` · `opts.topic` 1–32 chars `[A-Za-z0-9_-]` (a newer message with the same topic replaces the pending one) · `opts.timeout` seconds (default 30).
- `status` 201 = accepted; **`gone`** on 404/410 → delete that subscription; `retry_after` (text or `nothing`) on 429/503. The endpoint must be `https://` (plain `http://` only on loopback, for mocks). The service unreachable → error.
- **Don't confuse your errors with dead subscriptions.** A capability error (`Capability not granted: net("jmt17.google.com")` — a browser whose push service you didn't declare) or an unreachable service is *your* configuration or network: keep the subscription, add the host the error names, send again. Drop a subscription only on `gone`, or when the error names `subscription` (broken keys or endpoint). The scaffold's `POST /api/push/test` does exactly that in its `recover`. And keep the hosts exact — `net("*.google.com")` would open egress far beyond push.
- What's inside: RFC 8291/8188 `aes128gcm` — ECDH P-256 with an ephemeral key + a 16-byte salt from the OS CSPRNG per message, HKDF, AES-128-GCM, one 4096-byte record — and a VAPID ES256 JWT (RFC 8292) with a 12-hour `exp`. Protocol-internal randomness needs no `random` capability, like the TLS handshake. Not available in the wasm/pure profile (it needs sockets); `push_vapid_keys` is.

**Already on a provider?** OneSignal, Firebase Cloud Messaging, Pusher Beams and the like keep working: `http_post` to their REST API under `require net(...)` with `bearer(secret("PROVIDER_KEY"))`. Native push is an option, not a mandate.

## What this is not

Native APIs the web doesn't have — Bluetooth, NFC, home-screen widgets, background sync on iOS — live in a native client (Swift, Kotlin, Flutter) that talks to the same server through the OpenAPI it derives ([Build an API](/en/0.6.x/43-build-api)). Synsema does not compile native UI, and it never ships your secrets to a device.

## One binary, the whole app (engine v0.6.16+)

```sh
synsema build app.syn -o app --serve --bind 0.0.0.0 --port 8080 --domain app.example.com --tls-auto ops@example.com
./app                                   # the serve runtime with those flags baked in; Ctrl-C = ordered shutdown
```

`--serve` builds a **server binary**: the serve block's static mounts (`public/`) and the templates are bundled automatically and served from inside the file, with a content ETag — nothing to ship beside it. A bind is required (a distributable never guesses its interface): `--bind`, or the block's `bind "…"` clause (v0.6.18+); the other flags are the ones `synsema serve` takes, baked in. See [CLI § `synsema build`](/en/0.6.x/70-cli).

The same layout is a **desktop app** (engine v0.6.18+): `bind "127.0.0.1"`, `synsema build … --serve --no-console --icon public/icon.svg --bundle`, the browser as an app window, the service worker and the manifest making it installable from Edge/Chrome on `127.0.0.1` — [Your app on the desktop](/en/0.6.x/41c-desktop).

## Testing it on a real phone

The phone needs an **HTTPS origin** (localhost is only secure on the machine itself). Three ways, cheapest first:

- **A tunnel to your laptop:** `cloudflared tunnel --url http://localhost:8080` or `ngrok http 8080` gives a public HTTPS URL for the local `synsema serve` — install and push work on Android and iOS as in production. The URL changes per run; the subscriptions die with it.
- **Android over USB:** `adb reverse tcp:8080 tcp:8080`, then open `http://localhost:8080` in Chrome on the phone — `localhost` is a secure context there too, no certificate needed.
- **A VPS with `--domain … --tls-auto`** — the real thing; iOS needs a *trusted* certificate, a self-signed one won't do.

Automating it (Playwright/Puppeteer against the local server): close the browser in a `finally` and stop the `synsema serve` child when the run ends — a test that panics leaves a headless Chrome behind (on Windows look for `chrome.exe` with `ms-playwright` in its command line).

## Stores, if you want them

- **Google Play:** package the PWA as a Trusted Web Activity (PWABuilder or Bubblewrap). Play verifies you own the origin through `/.well-known/assetlinks.json` — put it under `public/.well-known/` and the static mount serves it as JSON.
- **Microsoft Store:** accepts a PWA directly (PWABuilder generates the package).
- **App Store:** needs a native wrapper (PWABuilder → Xcode); note that **Web Push does not work inside WKWebView** — iOS push is for the Home-Screen install, not for a wrapped app. This is Apple's line, not Synsema's.

## Next

- **[Frontend](/en/0.6.x/41-frontend)** — templates, layouts, static assets.
- **[Deploy](/en/0.6.x/71-deploy)** — `--domain` + `--tls-auto`, the one flag that makes iOS install it.
- **[Secrets](/en/0.6.x/21-secrets)** — why the VAPID private key is a `secret`, and `reveal()`'s audit.
