From 50 lines of Express to an iPhone app
August 30, 2026
This guide walks through the Parlane reference server,
examples/node-express: the smallest backend that speaks the REST
contract. You write it file by file and end with a native iPhone app
rendering your JSON. If you already have a backend, the same five pieces
are what you add to it.
Prefer Python? The same walk-through exists for FastAPI.
What you’ll build
An Express server that turns an API into an iPhone app: it serves a manifest and two JSON endpoints, and Parlane renders them as native streaming chat plus a dashboard with metrics and a chart.
Prerequisites
- Node.js 18 or later.
- The Parlane app on your iPhone (TestFlight link at the end).
- Your phone and computer on the same network, so the phone can reach the server.
1. Create the project
Create a directory and install the two dependencies:
mkdir reference-agent && cd reference-agent
npm init -y && npm pkg set type=module
npm install express qrcode-terminal
express serves the JSON. qrcode-terminal prints the connect QR code in
step 6. There is no Parlane SDK, which is the point: the
contract is three HTTP routes.
2. Serve the manifest
The manifest is how your server introduces itself: a name, an accent
color, and the surfaces it supports. Declare only what you serve; the app
hides the rest. Create src/manifest.js:
// src/manifest.js
export function buildManifest() {
return {
version: 1,
name: 'Reference Agent',
description: 'A minimal reference backend: canned chat and one dashboard.',
accent: '#5B8DEF',
surfaces: {
chat: { endpoint: 'rest', attachments: false },
dashboards: [
{ id: 'home', title: 'Home', source: '/ui/home', refresh: { mode: 'manual' } },
],
},
};
}
Every field except version is optional. The accent skins the app’s
screens; refresh.mode: 'manual' means the dashboard reloads on
pull-to-refresh instead of polling.
Then wire the routes. Create server.js:
// server.js
import express from 'express';
import { buildManifest } from './src/manifest.js';
import { buildHomeUi } from './src/dashboards/home.js';
import { handleChat } from './src/chat.js';
import { bearerAuth } from './src/auth.js';
export function createApp() {
const app = express();
app.use(express.json({ limit: '10mb' }));
app.use(bearerAuth);
app.get('/.well-known/parlane.json', (req, res) => res.json(buildManifest()));
app.get('/ui/:dashboardId', (req, res) => {
if (req.params.dashboardId === 'home') return res.json(buildHomeUi());
res.status(404).json({ error: { code: 'not_found', message: 'Unknown dashboard' } });
});
app.post('/chat', handleChat);
return app;
}That’s the whole contract surface: the manifest route, GET /ui/:id, and
POST /chat. The limit: '10mb' gives headroom for base64 attachments if
you enable them later; Express defaults to 100 KB, which rejects the first
screenshot someone shares.
3. Stream chat over SSE
Chat is one POST that answers with Server-Sent Events: delta for text
chunks, tool for activity indicators, and exactly one done or error
to finish. Create src/sse.js with three helpers:
// src/sse.js
export function sseHeaders(res) {
res.status(200);
res.set({
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
});
// Flush headers immediately so the client sees the stream start right away.
res.flushHeaders?.();
}
export function writeEvent(res, event, data) {
res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
}
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
// Stream text word by word so the client renders incrementally.
export async function streamWords(res, text, delayMs = 8) {
const words = text.split(' ');
for (let i = 0; i < words.length; i++) {
writeEvent(res, 'delta', { text: i === 0 ? words[i] : ' ' + words[i] });
await sleep(delayMs);
}
}
Honest note: the 8 ms delay per word is artificial. It exists so you can see incremental rendering work; a real backend forwards model tokens as they arrive and never sleeps.
Now the handler. Create src/chat.js:
// src/chat.js
import { sseHeaders, writeEvent, streamWords } from './sse.js';
export async function handleChat(req, res) {
const { conversation_id: conversationId = 'conv_1', message = '' } = req.body || {};
sseHeaders(res);
// A canned reply. Swap these lines for a real model call; the SSE
// contract stays identical.
writeEvent(res, 'tool', { tool: 'think', status: 'start', label: 'Thinking…' });
writeEvent(res, 'tool', { tool: 'think', status: 'end' });
await streamWords(res, `You said: "${message}". I'm a canned reference agent.`);
writeEvent(res, 'done', { conversation_id: conversationId });
res.end();
}
The full reference server’s pickReply branches on keywords (“dashboard”
gets a tool indicator, “code” gets a markdown reply with a fenced block)
and validates incoming attachments against the published attachment
schema. Both are worth stealing once the happy path works.
Voice costs you nothing extra here: speech runs on-device in the app, and
your server receives plain text at /chat like any other message.
Add auth while you’re here. Create src/auth.js, quoted verbatim from the
reference server:
// src/auth.js
export function bearerAuth(req, res, next) {
const required = process.env.AUTH_TOKEN;
if (!required) return next();
const header = req.get('authorization') || '';
const [scheme, token] = header.split(' ');
if (scheme === 'Bearer' && token === required) return next();
res.status(401).json({ error: { code: 'unauthorized', message: 'Missing or invalid bearer token' } });
}
With no AUTH_TOKEN set the server is open, which is acceptable for a
LAN demo and nothing else. Set it before you expose the server anywhere.
4. Add a dashboard
A dashboard is a JSON tree from a fixed catalog of 17 native component
types. The app renders real native components from the data, with no HTML
and no webview involved. Create src/dashboards/home.js:
// src/dashboards/home.js
export function buildHomeUi() {
return {
version: 1,
id: 'home',
title: 'Home',
root: {
type: 'stack',
props: { direction: 'vertical', spacing: 16 },
children: [
{ type: 'text', props: { value: 'Reference Agent status', style: 'title' } },
{
type: 'stack',
props: { direction: 'horizontal', spacing: 12, align: 'stretch' },
children: [
{ type: 'metric', props: { label: 'Uptime', value: '100%', intent: 'good' } },
{ type: 'metric', props: { label: 'Requests today', value: 128, delta: '+12', intent: 'neutral' } },
],
},
{
type: 'card',
props: { title: 'Requests per minute', footer: 'last 6 minutes, canned data' },
children: [
{
type: 'chart',
props: {
kind: 'line',
labels: ['-5m', '-4m', '-3m', '-2m', '-1m', 'now'],
series: [{ name: 'req/min', data: [3, 5, 4, 8, 6, 7] }],
},
},
],
},
],
},
};
}
The numbers are canned. In your backend, this function reads real state and returns real values; the shape is the part that transfers.
Two limits, enforced on purpose: max depth 12 and max 500 nodes per
document. Updates are full-replace only. There is no patch format, so a
response is trivially idempotent and your server never has to reason about
client-side tree state. The reference server also ships a second
dashboard, systems, with a toggle, a slider, a select, and a Save button
wired to POST /action; add it when you want interactive controls.
5. Run it
Create the entry point, index.js, and the QR printer, src/qr.js:
// index.js
import { createApp } from './server.js';
import { printConnectInfo } from './src/qr.js';
const port = Number(process.env.PORT) || 8787;
createApp().listen(port, '0.0.0.0', () => printConnectInfo(port));
// src/qr.js
import os from 'node:os';
import qrcodeTerminal from 'qrcode-terminal';
function lanIPs() {
const nets = os.networkInterfaces();
const ips = [];
for (const iface of Object.values(nets)) {
for (const addr of iface || []) {
if (addr.family === 'IPv4' && !addr.internal) ips.push(addr.address);
}
}
return ips;
}
export function printConnectInfo(port) {
const token = process.env.AUTH_TOKEN || '';
const urls = [`http://localhost:${port}`, ...lanIPs().map((ip) => `http://${ip}:${port}`)];
console.log('\nListening on:');
for (const u of urls) console.log(` ${u}`);
// Prefer a LAN address so a phone on the same network can scan and connect.
const primary = urls[urls.length - 1];
const tokenPart = token ? `&token=${encodeURIComponent(token)}` : '';
const connectUrl = `parlane://connect?url=${encodeURIComponent(primary)}${tokenPart}`;
console.log(`\nScan to connect:\n ${connectUrl}\n`);
qrcodeTerminal.generate(connectUrl, { small: true });
}Start it and check the manifest:
node index.js
curl http://localhost:8787/.well-known/parlane.jsonTo require a token on every request, set AUTH_TOKEN:
AUTH_TOKEN=sk_demo_123 node index.js
6. Connect from your phone
The terminal now shows a QR code encoding a connect deep link with your server’s LAN address (and the token, if you set one).
- Open Parlane on your iPhone and scan the QR code with the camera, or choose Connect and paste the URL.
- The app fetches the manifest, shows you a consent preview of what the server declares, and connects.
- Chat streams from
/chat. The Home dashboard renders from/ui/home, native metrics and a native chart, from JSON you wrote two minutes ago.
If the connection fails, check reachability first: localhost on your
computer is not localhost on your phone. The QR code prefers a LAN
address for exactly this reason, and both devices must be on the same
network. Reaching the server from outside your network is a separate
problem (a tunnel, or the relay for push), not part of this guide.
What you built
About 50 lines of Express wiring in server.js plus some plain data:
- A manifest at
/.well-known/parlane.jsondeclaring chat and one dashboard. POST /chatstreaming SSE that any model call can feed.GET /ui/homereturning a declarative native dashboard.- Optional bearer auth and a scan-to-connect QR code.
What you didn’t build: an Xcode project, a push pipeline, or an App Store submission. The app is the front end; your server stays the product.
Known limits of this exact server: the agent is canned keyword matching,
state lives in memory and resets on restart, and there’s no push (that
needs the relay and a surfaces.push declaration in the manifest).
To wire the same contract 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.
Get Parlane on your phone