Public API
Panes exposes a small public HTTP API under /api/pub/v1 so an external app on the same
machine — a dashboard, a provisioning script, a fleet console — can read the monitors and set
their commands without signing in to the web UI.
Base URL
https://127.0.0.1:31077/api/pub/v1
- Served on the same loopback address and port as the web UI. The port (
31077) and bind address are configurable with thePANES_LISTENenvironment variable, but the/api/pub/v1path prefix is fixed. - TLS is on by default with a locally-trusted certificate, so
https://works without a warning on the machine itself. Pass-ktocurlif you disabled trust or supplied your own cert. - The current API version is
0.1.0(reported by/health). It is pre-1.0 — treat the shape as not-yet-stable.
Authentication & access
The public API has no API keys and no session — by design. Access is constrained two ways instead: a browser Origin allowlist and a rate limit.
Origin allowlist
Each gated request is matched on its Origin header:
| Caller | Origin header | Result |
|---|---|---|
| Non-browser (curl, script, server) | absent | Allowed. No CORS headers are returned. Safe only because the server is loopback-bound. |
| Browser from an allowed origin | present, in allowlist | Allowed. The exact origin is echoed in Access-Control-Allow-Origin (never *), with Vary: Origin. |
| Browser from any other origin | present, not in allowlist | 403 {"error":"origin not allowed"} — the handler never runs. |
The allowlist is deny-by-default and managed by an admin in the web UI under
Settings → Origins (it lives on the authenticated admin API, not this one). Each entry is
normalized to scheme://host — lowercased, trailing slash stripped, http/https only, no
path, no wildcards — and the browser's Origin must match it byte-for-byte.
CORS preflight
A browser OPTIONS preflight to the write endpoints runs the same gate (so it is rate-limited
and origin-checked) and, on success, returns 204 No Content with:
Access-Control-Allow-Origin: <origin>
Vary: Origin
Access-Control-Allow-Methods: GET, POST, PUT, OPTIONS
Access-Control-Allow-Headers: content-type
Access-Control-Max-Age: 600
Access-Control-Allow-Credentials is never set (there are no cookies to send).
Rate limiting
- 120 requests per minute per IP, over a fixed 60-second window.
GET /healthandGET /openapi.jsonare exempt; every other endpoint (includingOPTIONSpreflight) counts.- Over the limit →
429{"error":"rate limit exceeded"}. There is noRetry-Afterheader, and no CORS headers are attached to a 429. - The rate-limit check runs before the origin check, so a throttled request from a disallowed
origin sees
429, not403.
Conventions
-
JSON, snake_case. All request and response fields — and all enum values — are snake_case. Send
Content-Type: application/jsonon request bodies. -
Error envelope. Errors raised by the API are a single-field object:
{ "error": "no such monitor" }The exception is malformed-request errors handled by the framework before the API runs — a missing/wrong
Content-Type(415), invalid JSON (400), or JSON that doesn't match the schema (422). Those use the framework's default shape, not the{"error":...}envelope. -
Writes are asynchronous to launch.
POSTandPUTpersist the command(s) and return (201/200) before the app is actually launched — a background reconcile does the launching. A2xxmeans "stored and scheduled", not "running". See write behavior.
Data types
PubCommand
The command object accepted by POST (one object) and
PUT (an array of them).
| Field | Type | Required | Default | Notes |
|---|---|---|---|---|
name | string | yes | — | Human-readable label. |
exe_path | string | yes | — | Absolute path to the executable, or a URL for the kiosk presets. |
preset | enum | no | "generic" | One of Preset. |
args | string | no | "" | Command-line arguments. Supports template variables. |
working_dir | string | null | no | null | Working directory. Ignored by the kiosk presets. |
placement | enum | no | "fullscreen" | One of Placement. |
custom_rect | CustomRect | null | no | null | Required geometry when placement is "custom". |
launch_at_startup | boolean | no | true | Launch on boot / reconcile. false = stored but not auto-launched. |
watchdog | boolean | no | true | Restart the process if it exits. |
max_restarts | integer | no | 5 | Crash-loop threshold: more exits than this within ~60s → crash-looping. |
launch_delay_s | integer | no | 0 | Seconds to wait before launching. |
id, ordinal, and enabled are not accepted — the server assigns the id, computes the
ordinal (launch order), and always stores the command as enabled.
CustomRect
Used only when placement is "custom". All four are numbers, expressed as percentages of
the monitor (0–100). If placement is "custom" but custom_rect is omitted, the window
fills the whole monitor.
| Field | Type | Notes |
|---|---|---|
x_pct | number | Left edge, % of monitor width. |
y_pct | number | Top edge, % of monitor height. |
w_pct | number | Width, % of monitor width. |
h_pct | number | Height, % of monitor height. |
Preset
"chrome_kiosk" · "edge_kiosk" · "generic" — see the
commands reference.
Placement
"fullscreen" · "maximized" · "left_half" · "right_half" · "top_half" ·
"bottom_half" · "custom" — see placement modes.
Template variables
Usable in args; expand to the command's resolved placement rect:
{{monitor.x}} · {{monitor.y}} · {{monitor.width}} · {{monitor.height}} ·
{{monitor.index}} · {{monitor.label}}.
Endpoints
GET /health
ungated no rate limitLiveness probe. No auth, no origin check, no rate limit.
- Request
- Response — 200
curl -sk https://127.0.0.1:31077/api/pub/v1/health
{ "status": "ok", "name": "panes", "version": "0.1.0" }
GET /openapi.json
ungatedThe OpenAPI 3 document for the public API, generated from the server code. Use it to generate typed clients.
The generated spec declares the 200/201/403/404/429 responses but omits 500 and the
framework body-parse errors (400/415/422). Treat the error reference
below as authoritative.
GET /monitors
origin-gated rate-limitedList the machine's monitors in a trimmed shape (no EDID/identity detail). width/height are
null when the monitor has no live geometry (i.e. currently disconnected).
- Request
- Response — 200
curl -sk https://127.0.0.1:31077/api/pub/v1/monitors
[
{
"id": "1f9c0e42-...",
"label": "Lobby left",
"connected": true,
"primary": false,
"width": 1920,
"height": 1080
}
]
| Field | Type | Notes |
|---|---|---|
id | string | Stable monitor id — use it in the command endpoints. |
label | string | User label, else the detected display name. |
connected | boolean | Whether the panel is currently attached. |
primary | boolean | Whether it's the primary display. |
width / height | integer | null | Live resolution; null when disconnected. |
GET /status
origin-gated rate-limitedLive status of every command process.
- Request
- Response — 200
curl -sk https://127.0.0.1:31077/api/pub/v1/status
[
{
"command_id": "7a1b...",
"state": "running",
"pid": 4242,
"placed": true,
"last_error": null
}
]
| Field | Type | Notes |
|---|---|---|
command_id | string | The command this status is for. |
state | enum | stopped · starting · running · crash_looping · waiting_for_monitor · failed. |
pid | integer | null | OS process id while running, else null. |
placed | boolean | Whether the window was actually positioned on its monitor. |
last_error | string | null | Most recent error, if any. |
POST /monitors/{id}/commands
origin-gated rate-limitedAppend one command to a monitor. Returns the new command's id.
- Path:
id— a monitor id fromGET /monitors. The monitor must exist in the store, but need not be currently connected. - Body: a single
PubCommand.
- Request
- Response — 201
curl -sk -X POST \
https://127.0.0.1:31077/api/pub/v1/monitors/1f9c0e42-.../commands \
-H 'Content-Type: application/json' \
-d '{
"name": "Dashboard",
"preset": "chrome_kiosk",
"exe_path": "https://dash.example.internal",
"placement": "fullscreen",
"launch_at_startup": true,
"watchdog": true
}'
{ "id": "7a1b..." }
PUT /monitors/{id}/commands
origin-gated rate-limitedReplace all of a monitor's commands with the supplied set. Existing commands on that monitor
are stopped and deleted first (a running kiosk browser is killed, not orphaned), then the new
set is created in array order. Send {"commands": []} to clear the monitor.
- Path:
id— as above. - Body:
{ "commands": [ PubCommand, ... ] }.
- Request
- Response — 200
curl -sk -X PUT \
https://127.0.0.1:31077/api/pub/v1/monitors/1f9c0e42-.../commands \
-H 'Content-Type: application/json' \
-d '{
"commands": [
{
"name": "Left dashboard",
"preset": "chrome_kiosk",
"exe_path": "https://a.example.internal",
"placement": "left_half"
},
{
"name": "Right dashboard",
"preset": "chrome_kiosk",
"exe_path": "https://b.example.internal",
"placement": "right_half"
}
]
}'
{ "ids": ["7a1b...", "9c3d..."] }
Write behavior & reconcile
Both write endpoints persist the command(s) and then trigger a background reconcile — they do
not wait for it. The 201/200 response means the command is stored and scheduled, not that
the process is up.
- Reconcile launches any command that is enabled and has
launch_at_startup: true. A command withlaunch_at_startup: falseis stored but not auto-launched. - If the target monitor is not currently connected, the write still succeeds; the command is
stored and its status becomes
waiting_for_monitor. It launches automatically when the monitor reconnects (matched by EDID — see Concepts). - Reconcile failures are logged on the host but are not reported in the HTTP response. Poll
GET /statusto confirm a command actually reachedrunning.
Error reference
| Status | Body | When |
|---|---|---|
400 Bad Request | framework default | Request body is not valid JSON. |
403 Forbidden | {"error":"origin not allowed"} | Origin header present but not allowlisted. |
404 Not Found | {"error":"no such monitor"} | Monitor id doesn't exist (write endpoints). |
415 Unsupported Media Type | framework default | Missing/incorrect Content-Type on a body. |
422 Unprocessable Entity | framework default | Valid JSON that doesn't match the schema (e.g. missing name). |
429 Too Many Requests | {"error":"rate limit exceeded"} | Over 120 req/min for this IP. No Retry-After. |
500 Internal Server Error | {"error":"<message>"} | Store / monitor-enumeration failure. |
Worked example
Discover the monitors, put a fullscreen dashboard on one, then poll until it's running.
BASE=https://127.0.0.1:31077/api/pub/v1
# 1. Find a monitor id.
MON=$(curl -sk "$BASE/monitors" | jq -r '.[0].id')
# 2. Replace that monitor's commands with a single kiosk dashboard.
curl -sk -X PUT "$BASE/monitors/$MON/commands" \
-H 'Content-Type: application/json' \
-d '{"commands":[{"name":"Dashboard","preset":"chrome_kiosk",
"exe_path":"https://dash.example.internal","placement":"fullscreen"}]}'
# 3. Poll status until it reports "running".
curl -sk "$BASE/status" | jq '.[] | {command_id, state, pid}'
To clone a whole wall across machines, the authenticated admin API exports and imports the
entire configuration as one document via GET/PUT /api/v1/config
({ version, monitors, commands }). Import applies commands only — monitors are discovered
hardware and are never imported. That endpoint requires an admin session and is separate from
this public API.