Put your own program on the phone
This page takes you from an empty file to a small application of your own on a Remora viewer: a focus timer you can start, pause and reset from your phone. Every listing is a whole file from the repository, and every one has been run against the real server.
You can follow it in JavaScript or Python, where you speak the protocol yourself, or in Go, where the SDK speaks it for you. Pick a language on any listing and the rest of the page follows.
Before you start
You need a copy of the repository, and the tools to run what is in it:
- Go 1.24 or later, to run the server from source.
- Node 20 or later, to build the viewer once so there is something to look at in a browser.
- For your own program, one of: Node 22.2 or later, which has a WebSocket client built in; Python with the
websocketspackage (pip install websockets); or Go.
git clone https://github.com/amnuts/remora
cd remoraThe samples on this page live in website/samples/ and are run from the repository root. They were last run with Node 25, Python 3.14 with websockets 17, and Go 1.26, on Windows.
Run the server
Build the viewer once:
cd clients/viewer
npm install
npm run build
cd ../..Then start the headless server from the repository root:
go run ./server/cmd/remora --insecure-viewer-addr 127.0.0.1:7312 --web clients/viewer/distIt prints where it is listening and a pairing code:
time=2026-11-10T23:18:39.732Z level=WARN msg="the viewer endpoint is also offered without TLS; device tokens cross the network in the clear" addr=127.0.0.1:7312
Remora server "Den PC" (id _XFe48bLtIoY)
data C:\Users\you\AppData\Roaming\remora
applications ws://127.0.0.1:7310/v1/app
viewers wss://127.0.0.1:7311/v1/viewer
viewers ws://127.0.0.1:7312/v1/viewer NO TLS: device tokens cross the network in the clear
web clients/viewer/dist at https://127.0.0.1:7311/
web clients/viewer/dist at http://127.0.0.1:7312/
pin WJ7mVvtgALX4xZUNNF_2shUcOzegZHRrO7IwUEFwf-g
Pair a viewer (until 23:29:35; the pin starts WJ7mVvtg):
code Q004-CJ7G
uri remora://pair?v=1&id=_XFe48bLtIoY&name=Den%20PC&host=127.0.0.1&port=7311&pin=…&code=…Three addresses matter:
- Applications connect to
ws://127.0.0.1:7310/v1/app. That is where your program goes. It listens on this machine only. - Viewers connect to port 7311 over TLS. That is where the phone app goes.
- The two extra options add a plain-HTTP listener on port 7312 that also serves the viewer as a web page, so that a browser on this machine can be a viewer while you develop. Leave them off when you only use the phone app.
Open http://127.0.0.1:7312/?tls=0 in a browser, type the pairing code, and choose Pair. You get an empty list of applications. Leave the page open: the next step puts something in it.
The server keeps its identity and paired devices in a data folder, so you pair once. The desktop server app uses the same folder; run one or the other, not both.
Three messages and a dial
An application with no images needs one WebSocket and three JSON messages: hello, register and state. This is all of it.
JavaScript
// The smallest Remora application: one WebSocket, three messages, one dial.
//
// node dial.mjs (Node 22 or later, with a Remora server running)
const ws = new WebSocket('ws://127.0.0.1:7310/v1/app', 'remora.v1.json');
const send = (message) => ws.send(JSON.stringify(message));
const manifest = {
specVersion: '1.0',
id: 'com.example.timer',
name: 'Focus timer',
version: '0.1.0',
components: { left: { type: 'dial', label: 'Minutes left', max: 25 } },
scenarios: { main: { landscape: { root: 'left' } } },
defaultScenario: 'main',
};
let left = 25;
ws.addEventListener('open', () => {
send({ type: 'hello', role: 'app', versions: ['1.0'] });
});
ws.addEventListener('message', (event) => {
const message = JSON.parse(event.data);
console.log('server:', JSON.stringify(message));
if (message.type === 'welcome') send({ type: 'register', manifest });
if (message.type === 'ready') {
// The dial is called "left", so it shows the state key "left".
send({ type: 'state', patch: { left } });
// Count down once a second, so there is something to watch.
setInterval(() => {
left = left > 0 ? left - 1 : 25;
send({ type: 'state', patch: { left } });
}, 1000);
}
});
ws.addEventListener('close', (event) => {
console.log(`The connection closed (code ${event.code}). Is the server running?`);
process.exit(1);
});Python
# The smallest Remora application: one WebSocket, three messages, one dial.
#
# pip install websockets
# python dial.py (with a Remora server running)
import json
import time
from websockets.sync.client import connect
manifest = {
"specVersion": "1.0",
"id": "com.example.timer",
"name": "Focus timer",
"version": "0.1.0",
"components": {"left": {"type": "dial", "label": "Minutes left", "max": 25}},
"scenarios": {"main": {"landscape": {"root": "left"}}},
"defaultScenario": "main",
}
with connect("ws://127.0.0.1:7310/v1/app", subprotocols=["remora.v1.json"]) as ws:
def send(message):
ws.send(json.dumps(message))
def wait_for(wanted):
while True:
message = json.loads(ws.recv())
print("server:", json.dumps(message))
if message["type"] == "error":
raise SystemExit(message["message"])
if message["type"] == wanted:
return message
send({"type": "hello", "role": "app", "versions": ["1.0"]})
wait_for("welcome")
send({"type": "register", "manifest": manifest})
wait_for("ready")
# The dial is called "left", so it shows the state key "left".
# Count down once a second, so there is something to watch.
left = 25
while True:
send({"type": "state", "patch": {"left": left}})
left = left - 1 if left > 0 else 25
time.sleep(1)Go
// The smallest Remora application, with the Go SDK: connect, then patch.
//
// go run ./website/samples/dial (from the repository root, with a Remora server running)
package main
import (
"context"
"log"
"time"
"github.com/amnuts/remora/sdk/go/client"
)
const manifest = `{
"specVersion": "1.0",
"id": "com.example.timer",
"name": "Focus timer",
"version": "0.1.0",
"components": { "left": { "type": "dial", "label": "Minutes left", "max": 25 } },
"scenarios": { "main": { "landscape": { "root": "left" } } },
"defaultScenario": "main"
}`
func main() {
// Connect sends hello and register, and returns once the server has said ready.
app, err := client.Connect(context.Background(), client.Manifest([]byte(manifest)), nil)
if err != nil {
log.Fatalf("could not register (is the server running?): %v", err)
}
defer app.Close()
log.Printf("registered with %s as instance %s", app.ServerName(), app.Instance())
// The dial is called "left", so it shows the state key "left".
// Count down once a second, so there is something to watch.
for left := 25; ; left-- {
if left < 0 {
left = 25
}
if err := app.Patch(map[string]any{"left": left}); err != nil {
log.Fatalf("the connection ended: %v", err)
}
time.Sleep(time.Second)
}
}Run it in a second terminal, from the repository root:
JavaScript
node website/samples/dial/dial.mjsPython
python website/samples/dial/dial.pyGo
go run ./website/samples/dialFocus timer appears in the viewer's list. Open it: a dial counts down from 25, one step a second.
The JavaScript and Python versions print what the server said:
server: {"type":"welcome","version":"1.0","server":{"id":"_XFe48bLtIoY","name":"Den PC"},"limits":{"messageBytes":1048576,"packageBytes":16777216,"stateBytes":262144,"updatesPerSecond":60}}
server: {"type":"ready","instance":"2u2RSFEbzVnREddUkOf9Zg","package":"e116d19f07708261ed9f628114fa0821ebcf947eb7aa4251ddd4739ecdbd3be8"}
server: {"type":"watchers","count":0}What happened, in order:
- The program opened a WebSocket to the application endpoint, asking for the subprotocol
remora.v1.json. The server refuses a connection that does not ask for it. hellosaid the program is an application that speaks protocol 1.0. The server answeredwelcome, with its name and its limits.registercarried the whole template inline, asmanifest. The server checked it and answeredready, with the ID of this running instance.statecarried a patch,{ "left": 25 }. The dial is calledleftand says nothing about where its value comes from, so it shows the state keyleft. That rule is implicit binding.
Stop the program and the application leaves the viewer's list. The server forgets an application's state when its connection closes.
Run the JavaScript with Node, not from a web page. The server turns away any application connection that carries an
Originheader, so that a page open in a browser cannot register itself as an application.
Grow the template
A dial is not a timer. The rest of this page builds the real thing, starting with its template. A template with no asset files is a single JSON document, the manifest, which here lives in its own file so that all three languages share it.
The manifest has three parts that matter: the state the program will publish, the components that show it, and the scenarios that arrange them.
State
state is the document's starting value, and it doubles as a list of the keys the template expects.
"state": {
"left": "25:00",
"secondsLeft": 1500,
"secondsTotal": 1500,
"minutes": 25,
"round": 1,
"running": false,
"startLabel": "Start",
"canReset": false
}A template cannot calculate. It has no arithmetic and no string functions, so the program publishes values in the form they are shown: the clock as the text "25:00", the button's label as "Start", and canReset as a ready-made true or false.
Components and bindings
A component is one drawn thing. This text has a fixed style and a format string; {round} is replaced by the state key round.
"round": {
"type": "text",
"text": { "format": "Round {round}" },
"style": { "size": 5, "color": "#A9C3C5" }
}The ring is a dial whose value and maximum are both bound to state. Its variants change its style while a condition holds: here, the fill turns amber while running is true.
"ring": {
"type": "dial",
"value": { "bind": "secondsLeft" },
"max": { "bind": "secondsTotal" },
"style": {
"showValue": false,
"track": { "color": "#153E4A" },
"fill": { "color": "#62E0CD" },
"needle": { "color": "#00000000" }
},
"variants": [
{
"when": { "bind": "running" },
"style": { "fill": { "color": "#FFB84A" } }
}
]
}The clock text has no text property at all. Its ID is left, so it shows the state key left:
"left": {
"type": "text",
"style": { "size": 13, "weight": "bold", "color": "#ECF4F1" }
}A button, and a slider
A button sends an action when it is pressed. With no action property, the action is named after the component: this one sends startPause. Its label is bound to state, so the program decides whether it reads Start, Pause or Resume. feedback is played by the phone itself the moment it is touched, without waiting for the program.
"startPause": {
"type": "button",
"label": { "bind": "startLabel" },
"feedback": { "animation": "scaleDown", "haptic": "medium" },
"style": {
"background": { "color": "#FFB84A" },
"border": { "radius": 3 },
"labelText": {
"size": 6,
"weight": "bold",
"color": "#1C1303"
}
}
}enabled takes a condition. While it is false the component is drawn faded and sends nothing:
"reset": {
"type": "button",
"label": "Reset",
"enabled": { "bind": "canReset" },
"feedback": { "animation": "scaleDown", "haptic": "light" },
"style": {
"border": { "color": "#2B5763", "radius": 3 },
"labelText": { "size": 5, "color": "#ECF4F1" }
}
}A slider shows a number from state and sends the number the user asks for. This one names its action, setLength, and switches itself off while the timer runs ("not": true turns the condition round).
"length": {
"type": "slider",
"value": { "bind": "minutes" },
"min": 5,
"max": 60,
"step": 5,
"label": { "format": "Length: {minutes} minutes" },
"action": "setLength",
"enabled": { "bind": "running", "not": true },
"style": {
"track": { "color": "#153E4A" },
"fill": { "color": "#62E0CD" },
"labelText": {
"size": 4,
"color": "#A9C3C5",
"align": "start"
}
}
}Layout
A scenario gives a layout for landscape, portrait, or both. The landscape layout is a grid: column and row sizes are relative weights, and map names an area for every cell. Cells that share a name merge, so clock fills the whole left column. Each area then holds a component, or a container of them. A stack draws its children on top of one another, which is how the clock text sits inside the ring.
"landscape": {
"grid": {
"columns": [5, 4],
"rows": [1, 3, 3, 3],
"gap": 3,
"map": [
["clock", "round"],
["clock", "start"],
["clock", "reset"],
["clock", "length"]
]
},
"areas": {
"clock": { "type": "stack", "children": ["ring", "left"] },
"round": "round",
"start": "startPause",
"reset": "reset",
"length": "length"
},
"style": { "background": { "color": "#0B2A34" }, "padding": 5 }
}The portrait layout is a single column instead. weight is a child's share of the space.
"portrait": {
"root": {
"type": "column",
"gap": 3,
"children": [
"round",
{
"type": "stack",
"weight": 7,
"children": ["ring", "left"]
},
{ "component": "startPause", "weight": 2 },
{ "component": "reset", "weight": 2 },
{ "component": "length", "weight": 2 }
],
"style": { "padding": 6 }
},
"style": { "background": { "color": "#0B2A34" } }
}Lengths such as gap, padding and text size are in layout units: one unit is 1% of the shorter side of the screen, so the template scales as a whole from a phone to a tablet. The schema reference lists every component, property and style key. The whole manifest is in the repository.
Listen for actions and answer them
Here is the timer. It registers the manifest above, counts down once a second, and does what the buttons ask.
JavaScript
// A focus timer you can start, pause and reset from your phone.
//
// node timer.mjs (Node 22 or later, with a Remora server running)
import { readFileSync } from 'node:fs';
const manifest = JSON.parse(readFileSync(new URL('./manifest.json', import.meta.url), 'utf8'));
// --- the timer --------------------------------------------------------------
const timer = { minutes: 25, total: 25 * 60, left: 25 * 60, running: false, round: 1 };
function tick() {
if (!timer.running) return;
timer.left -= 1;
if (timer.left === 0) {
timer.running = false;
timer.left = timer.total;
timer.round += 1;
}
}
function act(action, value) {
if (action === 'startPause') timer.running = !timer.running;
if (action === 'reset') {
timer.running = false;
timer.left = timer.total;
}
if (action === 'setLength' && !timer.running) {
timer.minutes = value;
timer.total = timer.left = value * 60;
}
}
// The state the template draws. A template cannot calculate, so anything it
// needs worked out (the clock text, the button's label) is worked out here.
function view() {
const started = timer.left < timer.total;
return {
left: `${Math.floor(timer.left / 60)}:${String(timer.left % 60).padStart(2, '0')}`,
secondsLeft: timer.left,
secondsTotal: timer.total,
minutes: timer.minutes,
round: timer.round,
running: timer.running,
startLabel: timer.running ? 'Pause' : started ? 'Resume' : 'Start',
canReset: started,
};
}
// --- talking to the server --------------------------------------------------
const ws = new WebSocket('ws://127.0.0.1:7310/v1/app', 'remora.v1.json');
const send = (message) => ws.send(JSON.stringify(message));
let registered = false;
let watchers = 0;
let sent = {}; // The state the server has from us.
// Sends only the keys that changed, and nothing at all while nobody is watching.
function publish() {
if (!registered || watchers === 0) return;
const now = view();
const patch = {};
for (const key of Object.keys(now)) {
if (now[key] !== sent[key]) patch[key] = now[key];
}
if (Object.keys(patch).length === 0) return;
send({ type: 'state', patch });
sent = now;
}
ws.addEventListener('open', () => {
send({ type: 'hello', role: 'app', versions: ['1.0'], client: { name: 'timer.mjs', version: '1.0.0' } });
});
ws.addEventListener('message', (event) => {
const message = JSON.parse(event.data);
switch (message.type) {
case 'welcome':
send({ type: 'register', manifest });
break;
case 'ready':
registered = true;
console.log(`Registered as instance ${message.instance}.`);
break;
case 'watchers':
// When the count rises from 0, this sends everything that changed while nobody was looking.
watchers = message.count;
console.log(`${watchers} viewer(s) watching.`);
publish();
break;
case 'action':
console.log(`Action: ${message.action}`, message.value ?? '');
act(message.action, message.value);
publish();
break;
case 'error':
console.error(`The server reported ${message.code}: ${message.message}`);
break;
}
});
ws.addEventListener('close', (event) => {
console.log(`The connection closed (code ${event.code}). Is the server running?`);
process.exit(1);
});
setInterval(() => {
tick();
publish();
}, 1000);Python
# A focus timer you can start, pause and reset from your phone.
#
# pip install websockets
# python timer.py (with a Remora server running)
import asyncio
import json
from pathlib import Path
import websockets
manifest = json.loads(Path(__file__).with_name("manifest.json").read_text(encoding="utf-8"))
# --- the timer ---------------------------------------------------------------
timer = {"minutes": 25, "total": 25 * 60, "left": 25 * 60, "running": False, "round": 1}
def tick():
if not timer["running"]:
return
timer["left"] -= 1
if timer["left"] == 0:
timer["running"] = False
timer["left"] = timer["total"]
timer["round"] += 1
def act(action, value):
if action == "startPause":
timer["running"] = not timer["running"]
if action == "reset":
timer["running"] = False
timer["left"] = timer["total"]
if action == "setLength" and not timer["running"]:
timer["minutes"] = value
timer["total"] = timer["left"] = value * 60
def view():
"""The state the template draws. A template cannot calculate, so anything
it needs worked out (the clock text, the button's label) is worked out here."""
started = timer["left"] < timer["total"]
return {
"left": f"{timer['left'] // 60}:{timer['left'] % 60:02d}",
"secondsLeft": timer["left"],
"secondsTotal": timer["total"],
"minutes": timer["minutes"],
"round": timer["round"],
"running": timer["running"],
"startLabel": "Pause" if timer["running"] else "Resume" if started else "Start",
"canReset": started,
}
# --- talking to the server ---------------------------------------------------
async def main():
async with websockets.connect("ws://127.0.0.1:7310/v1/app", subprotocols=["remora.v1.json"]) as ws:
link = {"registered": False, "watchers": 0, "sent": {}} # "sent": the state the server has from us.
async def send(message):
await ws.send(json.dumps(message))
async def publish():
"""Sends only the keys that changed, and nothing at all while nobody is watching."""
if not link["registered"] or link["watchers"] == 0:
return
now = view()
patch = {key: value for key, value in now.items() if link["sent"].get(key) != value}
if patch:
await send({"type": "state", "patch": patch})
link["sent"] = now
async def every_second():
while True:
await asyncio.sleep(1)
tick()
await publish()
await send({"type": "hello", "role": "app", "versions": ["1.0"],
"client": {"name": "timer.py", "version": "1.0.0"}})
ticking = asyncio.create_task(every_second())
try:
async for raw in ws:
message = json.loads(raw)
kind = message["type"]
if kind == "welcome":
await send({"type": "register", "manifest": manifest})
elif kind == "ready":
link["registered"] = True
print(f"Registered as instance {message['instance']}.")
elif kind == "watchers":
# When the count rises from 0, this sends everything that changed while nobody was looking.
link["watchers"] = message["count"]
print(f"{message['count']} viewer(s) watching.")
await publish()
elif kind == "action":
print("Action:", message["action"], message.get("value", ""))
act(message["action"], message.get("value"))
await publish()
elif kind == "error":
print(f"The server reported {message['code']}: {message['message']}")
finally:
ticking.cancel()
print("The connection closed.")
asyncio.run(main())Go
// A focus timer you can start, pause and reset from your phone, with the Go SDK.
//
// go run ./website/samples/timer (from the repository root, with a Remora server running)
package main
import (
"context"
_ "embed"
"fmt"
"log"
"time"
"github.com/amnuts/remora/sdk/go/client"
)
//go:embed manifest.json
var manifest []byte
// --- the timer ---------------------------------------------------------------
type timer struct {
minutes, total, left, round int
running bool
}
func (t *timer) tick() {
if !t.running {
return
}
t.left--
if t.left == 0 {
t.running = false
t.left = t.total
t.round++
}
}
func (t *timer) act(a client.Action) {
switch a.Name {
case "startPause":
t.running = !t.running
case "reset":
t.running = false
t.left = t.total
case "setLength":
if minutes, ok := a.Number(); ok && !t.running {
t.minutes = int(minutes)
t.total = t.minutes * 60
t.left = t.total
}
}
}
// view is the state the template draws. A template cannot calculate, so
// anything it needs worked out (the clock text, the button's label) is worked
// out here.
func (t *timer) view() map[string]any {
started := t.left < t.total
label := "Start"
if t.running {
label = "Pause"
} else if started {
label = "Resume"
}
return map[string]any{
"left": fmt.Sprintf("%d:%02d", t.left/60, t.left%60),
"secondsLeft": t.left,
"secondsTotal": t.total,
"minutes": t.minutes,
"round": t.round,
"running": t.running,
"startLabel": label,
"canReset": started,
}
}
// --- talking to the server ---------------------------------------------------
func main() {
watching := make(chan int, 8)
app, err := client.Connect(context.Background(), client.Manifest(manifest), &client.Options{
Name: "timer (Go)", Version: "1.0.0",
OnWatchers: func(count int) { watching <- count },
})
if err != nil {
log.Fatalf("could not register (is the server running?): %v", err)
}
defer app.Close()
log.Printf("registered as instance %s", app.Instance())
t := &timer{minutes: 25, total: 25 * 60, left: 25 * 60, round: 1}
watchers := 0
// The SDK sends what it is given; the server passes on only what changed.
// So this publishes the whole view, and nothing at all while nobody is watching.
publish := func() {
if watchers == 0 {
return
}
if err := app.Patch(t.view()); err != nil {
log.Printf("patch failed: %v", err)
}
}
second := time.NewTicker(time.Second)
defer second.Stop()
for {
select {
case <-app.Done():
log.Fatalf("the connection ended: %v", app.Err())
case watchers = <-watching:
log.Printf("%d viewer(s) watching", watchers)
case a := <-app.Actions():
log.Printf("action: %s %v", a.Name, a.Value)
t.act(a)
case <-second.C:
t.tick()
}
publish()
}
}JavaScript
node website/samples/timer/timer.mjsPython
python website/samples/timer/timer.pyGo
go run ./website/samples/timerOpen Focus timer in the viewer and press Start. The ring turns amber and the clock counts down. Turn the browser window tall and narrow and the portrait layout takes over.
When a button is pressed, the server sends the program an action message:
{ "type": "action", "action": "setLength", "value": 10, "device": "d_7Yc2" }action is the name the template declares. value depends on what was touched:
| Component | value |
|---|---|
button | The button's actionValue if it has one; otherwise there is no value. |
toggle | The state the user asked for: true or false. |
slider | The number the user asked for, within the slider's min and max. |
list row | The row's actionValue, or its position in the list. |
Three things are worth knowing before you write a handler:
- Actions are requests. The slider does not change the timer's length; it asks. The program changes its own state and publishes the result, and the viewer shows that. To refuse a request, do nothing: within a second the control goes back to the value in state.
- Actions are not acknowledged. There is no reply to send. The state you publish next is the answer.
- The server checks actions first. It passes one on only if the viewer is watching your application, your template declares an interactive component with that action name, and the value is the right type. Your handler never sees an action your template did not offer.
Send updates efficiently
A patch says what changed
The patch in a state message is a JSON Merge Patch. Objects merge key by key; any other value replaces what was there; null removes a key; an array is always replaced whole.
{ "type": "state", "patch": { "left": "24:59", "secondsLeft": 1499 } }You may send only what changed, or your whole state every time. The result on the phone is the same, because the server compares each patch with what it holds and passes on only the differences. The JavaScript and Python timers send just the changed keys, which keeps the messages small. The Go timer hands the SDK its whole view each second and lets the server do the comparing.
The welcome message carries the server's limits. updatesPerSecond is how often it is worth publishing: send faster and the server merges your patches together instead of rejecting them. A patch that would grow the state document past stateBytes is refused with an error whose code is stateTooLarge, and the connection stays open.
Nobody watching, nothing to send
After ready, and whenever the number changes, the server tells you how many viewers are looking at your application:
{ "type": "watchers", "count": 1 }While the count is 0 there is nobody to draw for, and a program can stop publishing. When it rises from 0, publish your current state promptly, because the server has only what you last sent. All three timers do this: publish returns early while nobody is watching, and the watchers message itself triggers a publish.
If your state is expensive to gather (polling sensors, querying a game), the watcher count is also the signal to stop gathering it.
Add assets and build a .remora archive
Images, sounds and fonts cannot travel inside register. A template that uses them is a package: a ZIP file, by convention named .remora, with manifest.json at its root and the assets anywhere beneath.
timer.remora
├── manifest.json
└── assets/
├── icon.png
└── face.svgThe manifest refers to assets by their path inside the archive, with forward slashes:
"icon": "assets/icon.png""style": {
"background": { "image": "assets/face.svg", "fit": "cover" },
"padding": 6
}What may go in:
| Kind | Formats | Used for |
|---|---|---|
| Images | PNG and JPEG always work. SVG and WebP are supported by the reference viewer; the specification asks viewers to support them but does not require it. | icon, image components, backgrounds, button icons, bar artwork. |
| Sounds | MP3, OGG, WAV | The sound in a component's feedback. Nothing else. |
| Fonts | TTF, OTF, WOFF2 | Named in the manifest's fonts, then used by name in text styles. |
No video. No path may be absolute, contain .., or use a backslash. The server also rejects an archive containing a symbolic link or two entries with the same path. Keep a package under 5 MB; the server's hard limit is packageBytes in welcome.
Register by hash
A package is identified by the SHA-256 of the archive's bytes. You do not send the archive when you register; you send its hash:
{ "type": "register", "package": "9b3c…64 hexadecimal characters…" }If the server already holds that package, it answers ready straight away. If not, it answers { "type": "packageRequired" }, and your next frame must be one binary WebSocket frame containing the archive. The server checks that its hash matches, validates it, and then answers ready.
That is why the archive should be built the same way every time: the same bytes give the same hash, and the upload happens once per server rather than once per run. The helpers below sort the files and leave out timestamps for that reason.
JavaScript
// Registers a template that has asset files, as a .remora archive, and then
// supplies an image made while the program runs.
//
// node package.mjs (Node 22.2 or later, with a Remora server running)
import { createHash } from 'node:crypto';
import { fileURLToPath } from 'node:url';
import { pack } from './pack.mjs';
const archive = pack(fileURLToPath(new URL('./template', import.meta.url)));
const hash = createHash('sha256').update(archive).digest('hex');
console.log(`The archive is ${archive.length} bytes; its hash is ${hash}.`);
const ws = new WebSocket('ws://127.0.0.1:7310/v1/app', 'remora.v1.json');
const send = (message) => ws.send(JSON.stringify(message));
// A row of dots, one for each round finished. Any image the program can
// produce or fetch works the same way: a cover, a chart, a photo.
function tally(done) {
const dots = Array.from({ length: done }, (_, i) => `<circle cx="${20 + i * 40}" cy="20" r="13" fill="#FFB84A"/>`);
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${done * 40} 40">${dots.join('')}</svg>`;
}
let done = 0;
function finishRound() {
done = done < 8 ? done + 1 : 1;
const path = `tally/${done}.svg`;
// A runtime asset is two frames: a JSON message saying what is coming, then the bytes.
send({ type: 'asset', path, contentType: 'image/svg+xml' });
ws.send(Buffer.from(tally(done)));
// State then refers to it as "runtime:" plus the path.
send({ type: 'state', patch: { done, tally: `runtime:${path}` } });
}
ws.addEventListener('open', () => {
send({ type: 'hello', role: 'app', versions: ['1.0'] });
});
ws.addEventListener('message', (event) => {
const message = JSON.parse(event.data);
switch (message.type) {
case 'welcome':
if (archive.length > message.limits.packageBytes) {
console.error(`The archive is too large for this server (${message.limits.packageBytes} bytes at most).`);
process.exit(1);
}
// Offer the package by its hash. The server asks for the bytes only if it does not have them.
send({ type: 'register', package: hash });
break;
case 'packageRequired':
console.log('The server does not have this package yet: uploading it.');
ws.send(archive);
break;
case 'ready':
console.log(`Ready. Instance ${message.instance} is showing package ${message.package}.`);
finishRound();
setInterval(finishRound, 5000);
break;
case 'error':
console.error(`The server reported ${message.code}: ${message.message}`);
break;
}
});
ws.addEventListener('close', (event) => {
console.log(`The connection closed (code ${event.code}). Is the server running?`);
process.exit(1);
});Python
# Registers a template that has asset files, as a .remora archive, and then
# supplies an image made while the program runs.
#
# pip install websockets
# python package.py (with a Remora server running)
import hashlib
import io
import json
import time
import zipfile
from pathlib import Path
from websockets.sync.client import connect
def pack(folder):
"""Builds a .remora archive: an ordinary ZIP with manifest.json at its root.
Files go in sorted, with no timestamps, so the same folder always gives the
same bytes, and therefore the same package hash."""
buffer = io.BytesIO()
with zipfile.ZipFile(buffer, "w") as archive:
for path in sorted(p for p in folder.rglob("*") if p.is_file()):
entry = zipfile.ZipInfo(path.relative_to(folder).as_posix()) # dated 1 January 1980
entry.compress_type = zipfile.ZIP_DEFLATED
archive.writestr(entry, path.read_bytes())
return buffer.getvalue()
def tally(done):
"""A row of dots, one for each round finished. Any image the program can
produce or fetch works the same way: a cover, a chart, a photo."""
dots = "".join(f'<circle cx="{20 + i * 40}" cy="20" r="13" fill="#FFB84A"/>' for i in range(done))
return f'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 {done * 40} 40">{dots}</svg>'
archive = pack(Path(__file__).with_name("template"))
package_hash = hashlib.sha256(archive).hexdigest()
print(f"The archive is {len(archive)} bytes; its hash is {package_hash}.")
with connect("ws://127.0.0.1:7310/v1/app", subprotocols=["remora.v1.json"]) as ws:
def send(message):
ws.send(json.dumps(message))
def wait_for(*wanted):
while True:
message = json.loads(ws.recv())
if message["type"] == "error":
raise SystemExit(f"The server reported {message['code']}: {message['message']}")
if message["type"] in wanted:
return message
send({"type": "hello", "role": "app", "versions": ["1.0"]})
welcome = wait_for("welcome")
if len(archive) > welcome["limits"]["packageBytes"]:
raise SystemExit(f"The archive is too large for this server ({welcome['limits']['packageBytes']} bytes at most).")
# Offer the package by its hash. The server asks for the bytes only if it does not have them.
send({"type": "register", "package": package_hash})
reply = wait_for("ready", "packageRequired")
if reply["type"] == "packageRequired":
print("The server does not have this package yet: uploading it.")
ws.send(archive)
reply = wait_for("ready")
print(f"Ready. Instance {reply['instance']} is showing package {reply['package']}.")
done = 0
while True:
done = done + 1 if done < 8 else 1
path = f"tally/{done}.svg"
# A runtime asset is two frames: a JSON message saying what is coming, then the bytes.
send({"type": "asset", "path": path, "contentType": "image/svg+xml"})
ws.send(tally(done).encode("utf-8"))
# State then refers to it as "runtime:" plus the path.
send({"type": "state", "patch": {"done": done, "tally": f"runtime:{path}"}})
time.sleep(5)Go
// Registers a template that has asset files, as a .remora archive, and then
// supplies an image made while the program runs. With the Go SDK.
//
// go run ./website/samples/package (from the repository root, with a Remora server running)
package main
import (
"context"
"embed"
"fmt"
"io/fs"
"log"
"strings"
"time"
"github.com/amnuts/remora/sdk/go/client"
)
// The template folder travels inside the program.
//
//go:embed all:template
var files embed.FS
// tally is a row of dots, one for each round finished. Any image the program
// can produce or fetch works the same way: a cover, a chart, a photo.
func tally(done int) []byte {
var dots strings.Builder
for i := range done {
fmt.Fprintf(&dots, `<circle cx="%d" cy="20" r="13" fill="#FFB84A"/>`, 20+i*40)
}
return fmt.Appendf(nil, `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 %d 40">%s</svg>`, done*40, dots.String())
}
func main() {
template, err := fs.Sub(files, "template")
if err != nil {
log.Fatal(err)
}
// PackFS builds the .remora archive: the same folder always gives the same bytes.
archive, err := client.PackFS(template)
if err != nil {
log.Fatal(err)
}
// Archive offers the package by its hash; the SDK uploads the bytes only if the server asks.
app, err := client.Connect(context.Background(), client.Archive(archive), &client.Options{
OnError: func(e *client.Error) { log.Printf("the server reported %s: %s", e.Code, e.Message) },
})
if err != nil {
log.Fatalf("could not register (is the server running?): %v", err)
}
defer app.Close()
log.Printf("ready: instance %s is showing package %s", app.Instance(), app.Package())
for done := 1; ; done++ {
if done > 8 {
done = 1
}
path := fmt.Sprintf("tally/%d.svg", done)
if err := app.UploadAsset(path, "image/svg+xml", tally(done)); err != nil {
log.Fatalf("the connection ended: %v", err)
}
// State refers to a runtime asset as "runtime:" plus its path.
if err := app.Patch(map[string]any{"done": done, "tally": "runtime:" + path}); err != nil {
log.Fatalf("the connection ended: %v", err)
}
time.Sleep(5 * time.Second)
}
}JavaScript
node website/samples/package/package.mjsPython
python website/samples/package/package.pyGo
go run ./website/samples/packageThe first run of the JavaScript or Python version prints that it is uploading the package; run it again and it does not. Node has no ZIP writer of its own, so the JavaScript version brings a small one:
// Builds a .remora archive from a folder, with nothing but Node.
//
// A .remora file is an ordinary ZIP with manifest.json at its root. This
// writer stores files uncompressed and leaves out timestamps, so the same
// folder always gives the same bytes, and therefore the same package hash.
import { readdirSync, readFileSync } from 'node:fs';
import { join, relative, sep } from 'node:path';
import { crc32 } from 'node:zlib';
export function pack(folder) {
const names = readdirSync(folder, { recursive: true, withFileTypes: true })
.filter((entry) => entry.isFile())
.map((entry) => relative(folder, join(entry.parentPath, entry.name)).split(sep).join('/'))
.sort();
if (!names.includes('manifest.json')) throw new Error(`${folder} has no manifest.json at its root`);
const files = [];
const directory = [];
let offset = 0;
for (const name of names) {
const data = readFileSync(join(folder, name));
const nameBytes = Buffer.from(name, 'utf8');
const checksum = crc32(data);
const header = Buffer.alloc(30);
header.writeUInt32LE(0x04034b50, 0); // local file header
header.writeUInt16LE(20, 4); // ZIP version needed
header.writeUInt16LE(0x0800, 6); // the name is UTF-8
header.writeUInt16LE(0, 8); // stored, not compressed
header.writeUInt16LE(0, 10); // time: midnight
header.writeUInt16LE(0x21, 12); // date: 1 January 1980
header.writeUInt32LE(checksum, 14);
header.writeUInt32LE(data.length, 18);
header.writeUInt32LE(data.length, 22);
header.writeUInt16LE(nameBytes.length, 26);
files.push(header, nameBytes, data);
const record = Buffer.alloc(46);
record.writeUInt32LE(0x02014b50, 0); // central directory record
record.writeUInt16LE(20, 4);
record.writeUInt16LE(20, 6);
record.writeUInt16LE(0x0800, 8);
record.writeUInt16LE(0, 10);
record.writeUInt16LE(0, 12);
record.writeUInt16LE(0x21, 14);
record.writeUInt32LE(checksum, 16);
record.writeUInt32LE(data.length, 20);
record.writeUInt32LE(data.length, 24);
record.writeUInt16LE(nameBytes.length, 28);
record.writeUInt32LE(offset, 42); // where this file's header starts
directory.push(record, nameBytes);
offset += header.length + nameBytes.length + data.length;
}
const directoryBytes = Buffer.concat(directory);
const end = Buffer.alloc(22);
end.writeUInt32LE(0x06054b50, 0); // end of central directory
end.writeUInt16LE(names.length, 8);
end.writeUInt16LE(names.length, 10);
end.writeUInt32LE(directoryBytes.length, 12);
end.writeUInt32LE(offset, 16);
return Buffer.concat([...files, directoryBytes, end]);
}Any ZIP tool produces a valid package, as long as manifest.json ends up at the root of the archive rather than inside a folder. In Go, client.PackFS builds the archive from a folder embedded in the program.
To keep this file about packaging, it shows a fixed clock rather than repeating the timer. The template it registers is in samples/package/template/.
When the server says no
A manifest is checked when it is registered, strictly: a misspelt field name is an error, not something quietly ignored. A rejection is an error message that says where the problem is, after which the server closes the connection:
{ "type": "error", "code": "invalidPackage", "fatal": true,
"message": "components.hp.type: \"gauge\" is not a component type in specVersion 1.0",
"details": { "path": "components.hp.type" } }Adding the $schema line from the samples to your own manifest lets an editor that understands JSON Schema catch most of these as you type.
Runtime assets
Some pictures cannot be in the package because nobody knows them until the program is running: an album cover, an actor's photo, a chart. An application supplies these while connected.
The sample above does it in finishRound (JavaScript), at the end of the file (Python), and with app.UploadAsset (Go). It is two frames: an asset message that says what is coming, then one binary frame with the file.
{ "type": "asset", "path": "tally/3.svg", "contentType": "image/svg+xml" }State then refers to the file as runtime: followed by its path, anywhere an image is bound:
{ "type": "state", "patch": { "done": 3, "tally": "runtime:tally/3.svg" } }In the template, tally is an image component with no src, so it shows whatever the state key tally names.
contentTypeis one ofimage/png,image/jpeg,image/svg+xmlorimage/webp. Only images can be runtime assets.- Uploading to a path that already exists replaces the file. The sample uses a new path for each new picture instead, so that the value in state changes and every viewer fetches it.
- Runtime assets belong to this run of the program. They are deleted when the connection closes.
- A server may cap how much one application uploads. A refused upload is an
errorwith the codeassetRejected; the connection stays open.
A viewer only ever loads images from the server it is connected to. There is no way to give it a web address.
Where to go next
- Run it on a phone. Build the Android app as described in the repository's README, then pair it by scanning the QR code the desktop server shows, or by typing your PC's address and the pairing code.
- Read the three examples. Emberfall, Rig and Marquee each use parts of the manifest this page did not reach: lists, toggles, image-filled bars, fonts, sounds, switching scenario from state.
- Keep the reference open. The schema reference has every field, component and message, and the specification has the rules behind them.
- Understand the whole system. How it works follows the messages between application, server and phone, and explains pairing and the security model.
- Write an SDK. Everything the JavaScript and Python samples do by hand is what an SDK would wrap. The Go SDK is the one that exists; other languages are open.