Light Table Remote Control API (LAN mode)
The Light Table runs an embedded HTTP/WebSocket server on TCP port 7878 while the Light Table screen is open and the remote server is started. Any client on the same network can control the table with the protocol below.
Versioning & compatibility
The API is versioned so it can evolve without breaking existing integrations:
GET /versionreports{ "apiVersion": 1, "app": "...", "device": "..." }.- Unversioned paths are permanent aliases for the current major version.
/stateand/commandbehave exactly like/v1/stateand/v1/command. - Changes within a major version are additive — new command
actions and new fields instate/messages. Older clients simply ignore what they don't know, so they keep working unchanged. - A breaking change ships as a new major (
/v2/...,apiVersion: 2) while/v1continues to work. Clients that care can branch onapiVersionfromGET /versionor the WebSockethellomessage.
HTTP endpoints (interactive)
The plain HTTP endpoints are documented as an OpenAPI spec and rendered below. The live WebSocket/LTRC protocol — state push, scenes, effects — is documented in full further down this page.
WebSocket / LTRC protocol
Two transports speak the same JSON message protocol:
| Transport | Who uses it | Framing |
|---|---|---|
| WebSocket | Browsers (the built-in web remote) and any WS client | RFC 6455 text frames |
| LTRC | The native iOS remote (works over peer-to-peer/AWDL too) | Client sends the 5 bytes LTRC\n, then newline-delimited JSON both directions |
Access to each transport can be disabled independently on the table
(Remote sheet → "How devices connect"). Disabled transports answer
403 Forbidden (HTTP/WS) or close immediately (LTRC).
Discovery (Bonjour)
When Nearby device access is enabled, the table advertises:
- Service type:
_lighttable._tcp - TXT record keys:
name(device name),ip(WiFi IPv4),port(7878)
Advertising includes peer-to-peer (AWDL), so nearby iOS devices can connect
without a shared network. Browsers can't use Bonjour — the web remote learns
about other tables from the peers message (below) or manual IP entry.
HTTP endpoints
| Method | Path | Description |
|---|---|---|
GET |
/ |
Serves the web remote UI (HTML) |
GET |
/state |
Current state as JSON (see state message) |
POST |
/command |
One-shot command; body is a command object (see below). When a PIN is required, include "pin": "1234" in the body; otherwise responds {"error":"authRequired"} |
OPTIONS |
any | CORS preflight (204) |
curl http://192.168.1.50:7878/state
curl -X POST http://192.168.1.50:7878/command \
-d '{"action":"brightness","value":0.5,"pin":"1234"}'
Responses send Access-Control-Allow-Origin: *; connections are one-shot
(Connection: close). The QR code shown on the table encodes
http://<ip>:7878/?pin=<pin> when a PIN is set; the web UI stores the pin
from the query string and removes it from the address bar.
WebSocket / LTRC session
Connect with WebSocket to any path (the web UI uses /ws), or open a TCP
connection and send LTRC\n. All messages are JSON objects with a type.
Authentication (only when the table requires a PIN)
On connect, the server sends:
{"type":"authRequired","name":"Studio iPad"}
Every message except auth is ignored (and re-answered with
authRequired) until the client authenticates:
{"type":"auth","pin":"1234"}
Server replies {"type":"authOK"} followed by the welcome bundle, or
{"type":"authFailed"} (connection stays open for another attempt).
When no PIN is required, the welcome bundle arrives immediately on connect.
Welcome bundle (server → client)
{"type":"hello","name":"Studio iPad","apiVersion":1}
{"type":"state", ...}
{"type":"peers","peers":[{"name":"Desk iPhone","host":"192.168.1.51","port":7878}]}
{"type":"scenes","scenes":[...]}
{"type":"fxPrograms","programs":[...]}
state (server → client, pushed on every change)
{
"type": "state",
"brightness": 0.75, // 0..1
"whiteBalance": 0.3, // 0..1 (warm → white); ignored when colorHex set
"colorHex": "FF4500", // RRGGBB, or null when using color temperature
"showGrid": true,
"gridSpacing": 42, // points, 20..150
"keepAwake": false, // screen auto-lock disabled
"fxMode": "off", // off | breathe | cycle | candle | custom:<uuid>
"fxSpeed": 1.0 // 0.25..2.0
}
State broadcasts are coalesced to ~25/s during slider drags. peers,
scenes, and fxPrograms are re-broadcast whenever they change.
Commands (client → server)
{"type":"command","action":"brightness","value":0.5} // 0..1, clamped
{"type":"command","action":"whiteBalance","value":0.3} // 0..1, clamped
{"type":"command","action":"color","hex":"4169E1"} // RRGGBB; null/omit = back to temperature
{"type":"command","action":"grid","enabled":true,"spacing":50} // spacing 20..150
{"type":"command","action":"keepAwake","enabled":true}
{"type":"command","action":"fx","mode":"breathe","speed":1.0} // effect + speed 0.25..2.0
{"type":"command","action":"reset"} // restore defaults (fx → off)
Commands don't get individual replies; the resulting state broadcast is
the acknowledgement.
Effects
Built-in fxMode values are off, breathe, cycle, and candle. A
table's user-programmed effects are addressed as custom:<uuid> and listed
in the fxPrograms message:
{"type":"fxPrograms","programs":[{"id":"<uuid>","name":"Sunset Loop"}]}
Request the list any time with {"type":"fx.list"}. To run an effect, send an
fx command with the desired mode (a built-in name or custom:<uuid>) and
speed. All effects are intentionally slow and smooth for photosensitivity
safety; custom programs are edited on the table itself, not over the API.
Scenes
{"type":"scenes.list"} // → scenes message
{"type":"scenes.apply","id":"<uuid>"}
{"type":"scenes.save","name":"Sunset"} // saves the table's current settings
{"type":"scenes.rename","id":"<uuid>","name":"Sunset 2"}
{"type":"scenes.delete","id":"<uuid>"}
The scenes message lists each scene as:
{"id":"<uuid>","name":"Sunset","brightness":0.8,"whiteBalance":0.2,
"showGrid":false,"gridSpacing":50,"customColorHex":"FF4500",
"lastUsed":1783123456.0}
Export
{"type":"export.scene","id":"<uuid>"} // one scene (.flps)
{"type":"export.db"} // whole library (.flpdb)
Server replies with the file inline:
{"type":"export","filename":"Sunset.flps","mime":"application/json",
"dataB64":"<base64 of the JSON file>"}
Both formats are JSON arrays of scene objects (the app's own
import/export format), so a .flpdb can be re-imported from Settings.
Keepalive
The server pings WebSocket clients every 25 s (frame-level ping; browsers answer automatically). LTRC connections rely on TCP; no application-level ping is sent.
Notes
- All traffic is plain HTTP/WS on the local network — the PIN deters casual meddling but is not transport security.
- The server only runs while the Light Table screen is open on the device.
- Limits: requests up to 64 KB, WebSocket frames up to 1 MB.