From a FastAPI backend to an iPhone app
September 1, 2026
This guide walks through the Parlane reference server,
examples/python-fastapi: the smallest FastAPI 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 Node? The same walk-through exists for Express.
What you’ll build
A FastAPI server that turns a Python backend 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
- Python 3.10 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, a virtual environment, and install three dependencies:
mkdir reference-agent && cd reference-agent
python3 -m venv .venv && source .venv/bin/activate
pip install fastapi uvicorn qrcode
fastapi serves the JSON, uvicorn runs it, and qrcode 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 manifest.py:
# manifest.py
def build_manifest():
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 main.py:
# main.py
import asyncio
import json
import os
import uvicorn
from fastapi import FastAPI, HTTPException, Request, status
from fastapi.responses import JSONResponse, StreamingResponse
from manifest import build_manifest
from dashboards import build_home_ui
from auth import check_bearer_auth
from qr import print_connect_info
app = FastAPI(title="Reference Agent")
# Bearer auth on every route. check_bearer_auth is a no-op until
# AUTH_TOKEN is set (see step 3). Error bodies use the contract's flat
# shape, {"error": {"code", "message"}} - not FastAPI's default
# {"detail": ...} wrapper, which the app does not parse.
def error_body(exc: HTTPException) -> dict:
if isinstance(exc.detail, dict) and "error" in exc.detail:
return exc.detail
code = "unauthorized" if exc.status_code == 401 else "error"
return {"error": {"code": code, "message": str(exc.detail)}}
@app.middleware("http")
async def auth_middleware(request: Request, call_next):
try:
await check_bearer_auth(request)
except HTTPException as exc:
return JSONResponse(
status_code=exc.status_code,
content=error_body(exc),
headers=exc.headers or {},
)
return await call_next(request)
@app.get("/.well-known/parlane.json")
async def get_manifest():
return build_manifest()
@app.get("/ui/{dashboard_id}")
async def get_dashboard(dashboard_id: str):
if dashboard_id == "home":
return build_home_ui()
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail={"error": {"code": "not_found", "message": "Unknown dashboard"}},
)That’s most of the contract surface: the manifest route and
GET /ui/{dashboard_id}. POST /chat joins them in the next step.
FastAPI returns any dict as JSON, and unlike Express it puts no default
cap on request body size, so base64 attachments need no extra config if
you enable them later.
One FastAPI trap worth knowing: HTTPException nests its payload under
a detail key by default, and the app does not parse that wrapper. The
error_body helper in step 1 unwraps every error into the flat
{"error": {"code", "message"}} shape the contract documents.
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. In FastAPI that’s a StreamingResponse wrapping an async
generator. Add the route to main.py:
# main.py (continued)
@app.post("/chat")
async def chat(request: Request):
body = await request.json()
conversation_id = body.get("conversation_id", "conv_1")
message = body.get("message", "")
# A canned reply. Swap these lines for a real model call; the SSE
# contract stays identical.
text = f'You said: "{message}". I\'m a canned reference agent.'
async def event_stream():
yield f'event: tool\ndata: {json.dumps({"tool": "list_dashboards", "status": "start", "label": "Looking up dashboards…"})}\n\n'
await asyncio.sleep(0.06)
yield f'event: tool\ndata: {json.dumps({"tool": "list_dashboards", "status": "end"})}\n\n'
# Stream text word by word so the client renders incrementally.
words = text.split(" ")
for i, word in enumerate(words):
delta_text = word if i == 0 else " " + word
yield f'event: delta\ndata: {json.dumps({"text": delta_text})}\n\n'
await asyncio.sleep(0.008)
yield f'event: done\ndata: {json.dumps({"conversation_id": conversation_id})}\n\n'
return StreamingResponse(event_stream(), media_type="text/event-stream")
The wire format is exactly event: <name>, newline, data: <json>, and
a blank line. No SSE library needed; f-strings cover it.
Honest note: the 8 ms asyncio.sleep 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. The full reference server’s
pick_reply branches on keywords (“dashboard” gets the tool indicator
shown above, “code” gets a markdown reply with a fenced block) and skips
tool events on the default echo reply. Deterministic replies keep its
tests honest.
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 auth.py, quoted verbatim from the
reference server:
# auth.py
import os
from fastapi import HTTPException, status, Request
async def check_bearer_auth(request: Request):
required_token = os.environ.get("AUTH_TOKEN")
if not required_token:
return # No auth required
auth_header = request.headers.get("authorization", "")
parts = auth_header.split(" ")
if len(parts) != 2 or parts[0] != "Bearer" or parts[1] != required_token:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Missing or invalid bearer token",
headers={"WWW-Authenticate": "Bearer"},
)
The middleware from step 2 applies it to every route. 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 dashboards.py:
# dashboards.py
def build_home_ui():
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’s build_home_ui
adds a table of conversations, an activity list, and a refresh button,
and it ships a second dashboard, systems, with a toggle, a slider, a
select, and a Save button wired to POST /action (see its action.py
and state.py); add those when you want interactive controls.
5. Run it
Add the entry point to the bottom of main.py, and create the QR
printer, qr.py:
# main.py (continued)
def main():
port = int(os.environ.get("PORT", 8787))
print_connect_info(port)
uvicorn.run(app, host="0.0.0.0", port=port)
if __name__ == "__main__":
main()
# qr.py
import os
import socket
from urllib.parse import urlencode
import qrcode
def get_lan_ips():
ips = []
try:
for ip in socket.gethostbyname_ex(socket.gethostname())[2]:
if ip and ip != "127.0.0.1":
ips.append(ip)
except (socket.error, IndexError):
pass
# Fallback: open a UDP socket toward a public address to learn which
# interface the OS routes through. Nothing is actually sent.
try:
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
s.connect(("8.8.8.8", 80))
ip = s.getsockname()[0]
s.close()
if ip not in ips:
ips.append(ip)
except Exception:
pass
return ips
def print_connect_info(port):
token = os.environ.get("AUTH_TOKEN", "")
urls = [f"http://localhost:{port}"]
for ip in get_lan_ips():
urls.append(f"http://{ip}:{port}")
print("\nListening on:")
for url in urls:
print(f" {url}")
# Prefer a LAN address so a phone on the same network can scan and connect.
primary = urls[-1] if len(urls) > 1 else urls[0]
params = {"url": primary}
if token:
params["token"] = token
connect_url = f"parlane://connect?{urlencode(params)}"
print(f"\nScan to connect:\n {connect_url}\n")
qr = qrcode.QRCode(version=1, box_size=10, border=2)
qr.add_data(connect_url)
qr.make(fit=True)
qr.print_ascii(invert=True)Start it and check the manifest:
python main.py
curl http://localhost:8787/.well-known/parlane.jsonTo require a token on every request, set AUTH_TOKEN:
AUTH_TOKEN=sk_demo_123 python main.py
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. If both lookups in get_lan_ips come back empty (it happens on
some VPNs and locked-down networks), the QR falls back to localhost and
the phone can’t reach it; check the printed URL before you scan. 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
A few small Python files, most of them plain data:
- A manifest at
/.well-known/parlane.jsondeclaring chat and one dashboard. POST /chatstreaming SSE from an async generator 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