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 websockets package (pip install websockets); or Go.
Shell
git clone https://github.com/amnuts/remora
cd remora

The 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:

Shell
cd clients/viewer
npm install
npm run build
cd ../..

Then start the headless server from the repository root:

Shell
go run ./server/cmd/remora --insecure-viewer-addr 127.0.0.1:7312 --web clients/viewer/dist

It prints where it is listening and a pairing code:

Output
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

dial/dial.mjs
// 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

dial/dial.py
# 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

dial/main.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

From the repository root
node website/samples/dial/dial.mjs

Python

From the repository root
python website/samples/dial/dial.py

Go

From the repository root
go run ./website/samples/dial

Focus 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:

Output
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:

  1. 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.
  2. hello said the program is an application that speaks protocol 1.0. The server answered welcome, with its name and its limits.
  3. register carried the whole template inline, as manifest. The server checked it and answered ready, with the ID of this running instance.
  4. state carried a patch, { "left": 25 }. The dial is called left and says nothing about where its value comes from, so it shows the state key left. 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 Origin header, 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.

timer/manifest.json, state
"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.

timer/manifest.json, components.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.

timer/manifest.json, components.ring
"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:

timer/manifest.json, components.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.

timer/manifest.json, components.startPause
"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:

timer/manifest.json, components.reset
"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).

timer/manifest.json, components.length
"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.

timer/manifest.json, scenarios.main.landscape
"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.

timer/manifest.json, scenarios.main.portrait
"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

timer/timer.mjs
// 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

timer/timer.py
# 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

timer/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

From the repository root
node website/samples/timer/timer.mjs

Python

From the repository root
python website/samples/timer/timer.py

Go

From the repository root
go run ./website/samples/timer

Open 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:

JSON
{ "type": "action", "action": "setLength", "value": 10, "device": "d_7Yc2" }

action is the name the template declares. value depends on what was touched:

Componentvalue
buttonThe button's actionValue if it has one; otherwise there is no value.
toggleThe state the user asked for: true or false.
sliderThe number the user asked for, within the slider's min and max.
list rowThe 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.

JSON
{ "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:

JSON
{ "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.

Files
timer.remora
├── manifest.json
└── assets/
    ├── icon.png
    └── face.svg

The manifest refers to assets by their path inside the archive, with forward slashes:

package/template/manifest.json, icon
"icon": "assets/icon.png"
package/template/manifest.json, scenarios.main.landscape.style
"style": {
  "background": { "image": "assets/face.svg", "fit": "cover" },
  "padding": 6
}

What may go in:

KindFormatsUsed for
ImagesPNG 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.
SoundsMP3, OGG, WAVThe sound in a component's feedback. Nothing else.
FontsTTF, OTF, WOFF2Named 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:

JSON
{ "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

package/package.mjs
// 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

package/package.py
# 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

package/main.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

From the repository root
node website/samples/package/package.mjs

Python

From the repository root
python website/samples/package/package.py

Go

From the repository root
go run ./website/samples/package

The 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:

package/pack.mjs
// 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:

JSON
{ "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.

JSON
{ "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:

JSON
{ "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.

  • contentType is one of image/png, image/jpeg, image/svg+xml or image/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 error with the code assetRejected; 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.