Frontend
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.
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:
-- 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 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:
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). 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:
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 (whatsw.jsreads as{title, body, url}) ·bytes·nothingfor a body-less "something changed". At most 3993 bytes — the encrypted body must stay under the services' 4096. Asecretpayload is an error; a secret inside a map travels redacted.opts.vapid(required):public(base64url text),private(asecretonly — fromsecret("VAPID_PRIVATE_KEY"),as_secret(...)orpush_vapid_keys(); a plain string is refused, exactly like the private key ofsign: whoever holds it can push to every user),subject(mailto:orhttps://). A public key that doesn't match the private one fails here, not as an opaque 401 from the service.opts.ttlseconds the service keeps an undelivered message (default 86400) ·opts.urgencyvery-low | low | normal | high·opts.topic1–32 chars[A-Za-z0-9_-](a newer message with the same topic replaces the pending one) ·opts.timeoutseconds (default 30).status201 = accepted;goneon 404/410 → delete that subscription;retry_after(text ornothing) on 429/503. The endpoint must behttps://(plainhttp://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 ongone, or when the error namessubscription(broken keys or endpoint). The scaffold'sPOST /api/push/testdoes exactly that in itsrecover. 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-hourexp. Protocol-internal randomness needs norandomcapability, like the TLS handshake. Not available in the wasm/pure profile (it needs sockets);push_vapid_keysis.
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). Synsema does not compile native UI, and it never ships your secrets to a device.
One binary, the whole app (engine v0.6.16+)§
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.
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.
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:8080orngrok http 8080gives a public HTTPS URL for the localsynsema 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 openhttp://localhost:8080in Chrome on the phone —localhostis 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 underpublic/.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.