Parlane

← Build

Notify your phone from any script

August 31, 2026

Your backend can already chat and render dashboards on your phone after the Express guide. This guide adds the missing sense: interruption. A cron job finishes, a training run converges, a scraper hits an error, and your lock screen tells you. By the end, any script on your machine sends a push with one HTTP call.

Two things to know up front. Push delivery needs Parlane Pro: pairing the relay is free and happens inside the connect scan, but delivery through the hosted relay is the paid part (see pricing). Notification titles and previews are visible to the relay and Apple. The additional encrypted payload is opaque, but it does not hide the preview. Use generic text for sensitive events.

What you’ll build

  • A push declaration in your manifest: one line.
  • A one-scan connect QR that pairs your server with the relay at the moment your phone connects.
  • A notify() function on your server that seals the payload, signs the request, and calls the relay’s POST /notify.
  • A small /notify-me route of your own, so a curl one-liner or a Python script can trigger the whole chain.

Where ntfy and Pushover fit

If a push notification is the only thing you need, ntfy and Pushover are excellent single-purpose tools, and nothing in this guide argues otherwise. The trade here is different: the backend that sends the push is also a full native app on your phone, so the notification deep-links into a chat thread or dashboard served by that same backend, and “what happened?” is one tap away instead of a second tool. The extra payload can be encrypted, and the relay is open source (AGPL) so you can audit exactly what it sees: parlane-ai/relay. The tunneling, queueing, and encryption parts of that relay are self-hostable; push delivery itself always routes through the hosted relay, because only Parlane holds the Apple key that can reach the app. The relay overview covers that split honestly.

Prerequisites

  • The server from the Express guide, or any backend that serves a Parlane manifest.
  • Parlane on your iPhone, installed from TestFlight or the App Store. Those builds get production APNs tokens; a local development build gets a sandbox token the hosted relay cannot deliver to, so a push sent to it never arrives.
  • Parlane Pro on that phone, with notifications allowed. Pairing works without Pro; delivery is the Pro part, and the relay says so in its response instead of failing silently.
  • Node.js 18 or later, plus Python 3 for the script example.

How a send works

Your server calls POST /notify on the hosted relay with three fields: serverId (public routing metadata from pairing), encryptedPayload (the real notification, sealed with a key only your server and your phone can derive), and apnsHint (the visible banner: title, body, and an optional deepLink). The relay validates the hint against spec/push.schema.json and forwards the sealed payload untouched; its logs are ids, roles, and byte counts, never content. The hint is what the lock screen renders, so when the content is sensitive, keep the hint generic. The current app displays the hint and does not decrypt the sealed payload into a private preview. Fetch sensitive details directly from your server after the user opens the app.

1. Declare push in the manifest

Add one line to src/manifest.js from the previous guide:

// src/manifest.js
surfaces: {
  chat: { endpoint: 'rest', attachments: false },
  dashboards: [
    { id: 'home', title: 'Home', source: '/ui/home', refresh: { mode: 'manual' } },
  ],
  push: { relay: true },
},

That boolean is the whole switch. Without it there is no push; with it the app wires itself to the hosted relay. You don’t set a relay URL, and you never touch Apple credentials: no .p8 key, no Team ID, no APNs topic. Only the hosted relay holds the key that can reach the app.

2. Pair your server with the relay

Install the one new dependency:

npm install libsodium-wrappers

libsodium-wrappers is the audited reference implementation of exactly the primitives this contract uses; the relay and the app run the same library. Create src/relay.js:

src/relay.js
// src/relay.js
import { createHash, createHmac } from 'node:crypto';
import { readFileSync, writeFileSync } from 'node:fs';
import _sodium from 'libsodium-wrappers';

const RELAY = 'https://relay.parlane.ai';
const FILE = 'relay-credentials.json';

let sodium;
export async function initCrypto() {
  await _sodium.ready;
  sodium = _sodium;
}

const b64u = (bytes) => sodium.to_base64(bytes, sodium.base64_variants.URLSAFE_NO_PADDING);
const fromB64u = (str) => sodium.from_base64(str, sodium.base64_variants.URLSAFE_NO_PADDING);

export const loadCreds = () => JSON.parse(readFileSync(FILE, 'utf8'));
const saveCreds = (creds) => writeFileSync(FILE, JSON.stringify(creds, null, 2), { mode: 0o600 });

// v2 relay auth: the signature covers the exact raw body, so build the body
// first, then sign those bytes. A GET signs the hash of the empty string.
function bearer(creds, rawBody) {
  const ts = Date.now();
  const bodyHash = createHash('sha256').update(rawBody).digest('hex');
  const sig = createHmac('sha256', creds.serverAuthKey)
    .update(`${creds.serverId}:server:${ts}:${bodyHash}`)
    .digest('base64url');
  return `Bearer v2.${ts}.${sig}`;
}

async function relayPost(path, payload, creds) {
  const body = JSON.stringify(payload);
  const headers = { 'content-type': 'application/json' };
  if (creds) headers.authorization = bearer(creds, body);
  const res = await fetch(`${RELAY}${path}`, { method: 'POST', headers, body });
  if (!res.ok) throw new Error(`${path} -> ${res.status} ${await res.text()}`);
  return res.json();
}

// One-time self-bootstrap (sanctioned by the integration kit): claim a pairing
// against a throwaway key to obtain a serverId + serverAuthKey, then discard
// the throwaway. Real phones pair later by redeeming one-scan tokens.
export async function bootstrap() {
  const me = sodium.crypto_kx_keypair();
  const throwaway = sodium.crypto_kx_keypair();
  const { code } = await relayPost('/pair/start', { appPubKey: b64u(throwaway.publicKey) });
  const claim = await relayPost('/pair', { code, serverPubKey: b64u(me.publicKey) });
  saveCreds({
    serverId: claim.serverId,
    serverAuthKey: claim.serverAuthKey,
    serverPubKey: b64u(me.publicKey),
    serverPrivKey: b64u(me.privateKey),
    appPubKey: null, // filled in when your phone redeems a pair token
  });
  return loadCreds();
}

The bootstrap runs once and writes relay-credentials.json. Treat that file like a password: serverAuthKey authenticates every later call, and serverPrivKey is half of the encryption channel. The relay never sees the private key. It stores public keys, relay authentication credentials, and routing data.

3. Put a pair token in the connect QR

The recommended pairing path is one scan: your server pre-mints a single-use token, embeds it in the connect QR, and the app redeems it the moment the user scans, so connecting and pairing are the same gesture. Append to src/relay.js:

src/relay.js (pairing)
// src/relay.js (continued)

// Mint a one-time pair token for the connect QR. Single use, five-minute TTL.
export async function preparePairToken(creds) {
  const prepared = await relayPost(
    '/pair/prepare',
    { serverId: creds.serverId, serverPubKey: creds.serverPubKey },
    creds,
  );
  return prepared.pairToken;
}

// Poll until the phone redeems the token, then persist its public key.
// Without appPubKey the server cannot derive the session key, and every
// sealed payload it sent would be undecryptable.
export async function waitForRedeem(creds, pairToken) {
  const url = `${RELAY}/pair/prepare/status?pairToken=${encodeURIComponent(pairToken)}`;
  for (;;) {
    const res = await fetch(url, {
      headers: { authorization: bearer(creds, ''), 'x-server-id': creds.serverId },
    });
    if (res.status === 410) throw new Error('pair token expired before the scan; restart to mint a fresh one');
    // A transient 5xx deserves a retry with backoff; this minimal loop treats it as fatal.
    if (!res.ok) throw new Error(`status poll -> ${res.status}`);
    const status = await res.json();
    if (status.state === 'REDEEMED') {
      saveCreds({ ...creds, appPubKey: status.appPubKey });
      return;
    }
    await new Promise((resolve) => setTimeout(resolve, 2000));
  }
}

The poll is required, not a nicety. Your server minted the token before the app ever ran, so it has never seen the phone’s public key; until the poll returns REDEEMED with appPubKey, it cannot derive the session key that seals payloads.

Extend the QR printer from the previous guide with two params:

src/qr.js (changed lines)
// src/qr.js: printConnectInfo now takes the pairToken and appends two params.
export function printConnectInfo(port, pairToken) {
  // ...unchanged until connectUrl...
  const relayPart =
    `&relay=${encodeURIComponent('https://relay.parlane.ai')}` +
    `&pair=${encodeURIComponent(pairToken)}`;
  const connectUrl =
    `parlane://connect?url=${encodeURIComponent(primary)}${tokenPart}${relayPart}`;
  // ...rest unchanged
}

Then wire it together in index.js:

// index.js
import { createApp } from './server.js';
import { printConnectInfo } from './src/qr.js';
import { initCrypto, loadCreds, bootstrap, preparePairToken, waitForRedeem } from './src/relay.js';

await initCrypto();

let creds;
try {
  creds = loadCreds();
} catch {
  creds = await bootstrap();
}
const pairToken = await preparePairToken(creds);

const port = Number(process.env.PORT) || 8787;
createApp().listen(port, '0.0.0.0', () => printConnectInfo(port, pairToken));

waitForRedeem(creds, pairToken).then(
  () => console.log('Phone paired. Push is live.'),
  (err) => console.error(`Pairing incomplete: ${err.message}`),
);

Start the server and scan the QR with your phone. If you already connected during the previous guide, scan again: the app runs the connect flow and completes pairing this time. The pair token lives five minutes; if it expires before you scan, restart the server for a fresh one. After the redeem, the app registers its device push token with the relay on its own. You do nothing for that step.

4. Seal and send

Now the send itself: derive the session key, seal the payload, sign the request, post it. Append to src/relay.js:

// src/relay.js (continued)

// Same primitives as the relay's reference implementation
// (src/crypto/e2e.ts): X25519 crypto_kx agreement, XChaCha20-Poly1305 seal.
export async function notify(title, body = '', deepLink) {
  const creds = loadCreds();
  if (!creds.appPubKey) throw new Error('no phone paired yet: scan the connect QR first');

  const session = sodium.crypto_kx_server_session_keys(
    fromB64u(creds.serverPubKey),
    fromB64u(creds.serverPrivKey),
    fromB64u(creds.appPubKey),
  );
  const content = { title, body, ...(deepLink ? { deepLink } : {}) };
  const nonce = sodium.randombytes_buf(sodium.crypto_aead_xchacha20poly1305_ietf_NPUBBYTES);
  const ciphertext = sodium.crypto_aead_xchacha20poly1305_ietf_encrypt(
    JSON.stringify(content), null, null, nonce, session.sharedTx,
  );
  const sealed = { n: b64u(nonce), c: b64u(ciphertext) };

  return relayPost('/notify', {
    serverId: creds.serverId,
    encryptedPayload: JSON.stringify(sealed),
    apnsHint: content,
  }, creds);
}

Here the sealed payload and the hint carry the same content. The relay and Apple can read the hint; sealing a second copy does not make the visible preview private. For sensitive sends, use a generic hint such as { title: 'Update' }. The current iOS app does not decrypt the sealed payload into a notification preview. Let the user open the connected app to fetch sensitive details directly from your server.

Read the response instead of trusting the status code. Every 200 has the same shape: delivered, deviceCount, and transport are always present, and note appears only when deviceCount is 0:

{ "delivered": 1, "transport": "apns", "deviceCount": 1 }

deviceCount: 0 means the relay holds no registered device for this pairing and sent nothing. The usual causes: the user isn’t on Parlane Pro (pairing is free; delivery is Pro) or turned notifications off. The note says which, in user-ready words, so surface it rather than reporting a generic failure. delivered below deviceCount means the provider rejected stale tokens, which the relay prunes on the spot.

5. Notify from any script

The sealing key never leaves your server, which is why a raw curl to the relay can’t work and why that’s a feature. So give your server one route that wraps notify(). This route is yours, not part of the Parlane contract:

// server.js: one route of your own, behind the same bearerAuth
import { notify } from './src/relay.js';

app.post('/notify-me', async (req, res) => {
  const { title, body, deepLink } = req.body || {};
  if (!title) {
    return res.status(400).json({ error: { code: 'bad_request', message: 'title is required' } });
  }
  try {
    res.json(await notify(title, body, deepLink));
  } catch (err) {
    res.status(502).json({ error: { code: 'notify_failed', message: err.message } });
  }
});

Now any script reaches your lock screen. From a shell, a cron job, or a CI step:

curl -s -X POST http://localhost:8787/notify-me \
  -H 'content-type: application/json' \
  -H "authorization: Bearer $AUTH_TOKEN" \
  -d '{"title":"Backup finished","body":"nightly rsync: 0 errors"}'

From Python, standard library only:

# notify.py
import json, os, urllib.request

def notify(title, body="", deep_link=None):
    payload = {"title": title, "body": body}
    if deep_link:
        payload["deepLink"] = deep_link
    req = urllib.request.Request(
        os.environ.get("NOTIFY_URL", "http://localhost:8787/notify-me"),
        data=json.dumps(payload).encode(),
        headers={
            "content-type": "application/json",
            "authorization": "Bearer " + os.environ["AUTH_TOKEN"],
        },
    )
    with urllib.request.urlopen(req) as res:
        return json.load(res)

if __name__ == "__main__":
    result = notify(
        "Training run finished",
        "loss 0.041 after 12 epochs",
        deep_link={"target": "dashboard", "id": "home"},
    )
    print(result)  # {'delivered': 1, 'transport': 'apns', 'deviceCount': 1}

Prefer to skip the Node helper and seal from Python directly? The relay contract is language neutral: X25519 crypto_kx key agreement, XChaCha20-Poly1305 sealing, and HMAC-SHA256 v2 signatures. PyNaCl exposes all three. The byte-exact reference is src/crypto/e2e.ts and src/ws/auth.ts in the relay repository, and the README there documents every endpoint and status code.

6. Tap it

The deepLink decides where a tap lands: { "target": "dashboard", "id": "home" } opens the dashboard from the previous guide, { "target": "thread", "id": "<conversation_id>" } opens a chat thread, and omitting it opens the app’s default surface. That’s the payoff over a plain notifier: the Python example above drops you onto the live dashboard next to the number that prompted the push, and the chat about it is one tab away.

When it doesn’t arrive

Work through the response before blaming the network:

Symptom Cause Fix
deviceCount: 0 with a note The user isn’t on Pro, or notifications are off The note says which; show it to the user
delivered below deviceCount Stale device tokens None needed; the relay prunes them automatically
200 with delivered: 1, no banner The phone runs a local development build, so its token is sandbox Test against a TestFlight or App Store build
401 unauthorized Signature not over the exact bytes sent, or timestamp outside the 60-second skew Build the body first, sign those bytes; check the clock
404 unknown_server This relay holds no pairing for that serverId Bootstrap again, mint a token, rescan
410 pair_token_expired on the poll More than five minutes between mint and scan Restart the server, scan the fresh QR

What you built

  • surfaces.push.relay: true: the one-line manifest switch.
  • A one-scan connect QR that pairs the relay with no code to read across.
  • notify(): a signed push request with an encrypted payload and visible preview, about 40 lines including the auth signature.
  • /notify-me: your own doorbell, reachable from curl, Python, cron, or anything else that speaks HTTP.

Honest limits: delivery requires the hosted relay and a Pro subscription, because only Parlane holds the Apple key for the app; there is no self-hosted push path today. The apnsHint transits Apple’s servers as visible notification fields, so keep it generic when it matters. And the credentials file on your server is the whole identity of this pairing; leaking it lets someone else ring your phone.

To wire the same push path into the backend you already have, use the prompt below with your coding agent.

The prompt

Working on your own backend instead of the example? Paste this into your coding agent. It carries the whole contract, so the agent can wire your endpoints and check its work against the hosted schemas.

paste into your coding agent
Make my app Parlane-ready.

Parlane is a universal native mobile client. It renders my backend natively — chat,
voice, dashboards, push — from plain JSON I serve. There is no SDK. I implement a tiny
contract; any of the following it can already do (MCP) it reuses.

Implement chat unless I explicitly tell you not to. Chat is the core surface: voice rides
on it with no extra server work (speech runs on-device), and a connected app without chat
feels broken on first open. Wire /chat to whatever conversational or agent capability my
backend has; if it truly has none, ask me before omitting it, and then leave the chat
surface undeclared in the manifest so the client hides those tabs.

Do this:
1. Serve an app manifest at /.well-known/parlane.json (or an MCP resource
   "parlane://manifest"). Declare only the surfaces I actually support. All fields are
   optional. Validate it against spec/manifest.schema.json.
   Ask me about the look: accent, colors, dark/light — declare a theme in the manifest to
   skin my app's screens.

2. If I don't already speak MCP, add the REST fallback:
   - POST /chat {conversation_id, message, attachments?} -> Server-Sent Events with these
     event types: `delta` {text}, `tool` {tool,status,label?}, `done` {conversation_id,
     message_id?}, `error` {code,message}. End with exactly one `done` or `error`.
   - GET /ui/{dashboard_id} -> a UI document (spec/ui-document.schema.json): a tree from the
     17-type catalog {stack, card, text, metric, chart, table, list, image, button, input,
     toggle, slider, select, progress, badge, divider, map}. version:1, single root, max
     depth 12, max 500 nodes. Full-replace only — no patch format.
   - POST /action {action_id, tool, params, dashboard_id?} -> {ui} (a full replacement UI
     document) or {ack:true}. Interactive components carry an action envelope
     {tool, params?, confirm?, refresh:"self"|"none"|<dashboardId>}; inputs bind values by a
     `bind` name that I merge into params.

3. Require Authorization: Bearer <token> on every request. Optionally print a connect QR
   encoding parlane://connect?url=...&token=...
   (Advanced, MCP transport only — skip unless I ask: to require OAuth 2.1 instead of a
   static token, gate the MCP endpoint so an unauthenticated request returns 401 with a
   WWW-Authenticate protected-resource pointer; the client then runs the full OAuth login
   itself — discovery, dynamic client registration, PKCE, and refresh. See §6a.)

4. For push (optional, but this is exactly how to turn it on): to enable push notifications you MUST
   declare surfaces.push { relay: true } in the manifest. Without that flag there is no push. That
   one flag wires the app to the Parlane-operated hosted relay automatically; do NOT set
   relayUrl, the hosted relay is the default (only set it to override the host, which is rare).
   Preferred pairing: pre-mint a pairing token (POST /pair/prepare) and put it plus the relay in the
   connect QR (parlane://connect?...&relay=...&pair=...) so a single scan connects AND pairs
   the relay with zero extra user steps; if my server does not pair at connect, the app offers a
   manual pairing fallback. CRITICAL for one-scan: after the app redeems the QR token my server never
   saw the phone's public key, so poll GET /pair/prepare/status?pairToken=... until it returns
   { state:"REDEEMED", appPubKey } and derive the session key from that appPubKey before sending any
   encrypted envelope. Trigger notifications with POST /notify carrying an encrypted payload
   plus a visible apnsHint matching spec/push.schema.json; no persistent WebSocket is needed
   for push. The relay and Apple can read apnsHint, and the current iOS app does not decrypt
   the sealed payload into a private preview. Use generic hints for sensitive events.
   I never supply Apple credentials; Parlane owns the only APNs key
   for its app and a self-hosted relay cannot deliver push. Test push against a TestFlight build
   (production APNs), not a dev build.

5. For the share sheet (optional): declare surfaces.share { accepts: [kinds I handle], tool }.
   The app only offers my app for the kinds I list — include "image"/"file" if I ingest
   screenshots or documents, or I am never offered for them. My share.tool receives
   params.attachments[] (images/files as base64 data, per spec/attachment.schema.json) plus flat
   note/url/text. Raise the tool endpoint's body limit to >=20 MB (nginx client_max_body_size,
   framework JSON limits) — base64 adds ~33% over the 10 MB cap, so a default 1 MB limit 413s on a
   screenshot — and enforce the 10 MB decoded cap server-side.
   If I only handle some file types, add surfaces.share.acceptsMimes (e.g. ["image/*","application/pdf"])
   so the app pre-filters unsupported types before upload; and reject bad shares with the standard codes
   {error:{code,message,details?}} — unsupported_kind / unsupported_mime {mime,accepted?} / attachment_too_large {maxBytes} — so failures tell the user what to fix.

Constraints: dashboards are declarative data, never code. Keep all user-visible copy in the
JSON I serve. Follow the schemas in spec/ exactly and test against spec/examples/.

Get Parlane on your phone

Request TestFlight accessOn TestFlight now, App Store at launch