Skip to main content

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 the PANES_LISTEN environment variable, but the /api/pub/v1 path prefix is fixed.
  • TLS is on by default with a locally-trusted certificate, so https:// works without a warning on the machine itself. Pass -k to curl if 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:

CallerOrigin headerResult
Non-browser (curl, script, server)absentAllowed. No CORS headers are returned. Safe only because the server is loopback-bound.
Browser from an allowed originpresent, in allowlistAllowed. The exact origin is echoed in Access-Control-Allow-Origin (never *), with Vary: Origin.
Browser from any other originpresent, not in allowlist403 {"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 /health and GET /openapi.json are exempt; every other endpoint (including OPTIONS preflight) counts.
  • Over the limit → 429 {"error":"rate limit exceeded"}. There is no Retry-After header, 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, not 403.

Conventions

  • JSON, snake_case. All request and response fields — and all enum values — are snake_case. Send Content-Type: application/json on 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. POST and PUT persist the command(s) and return (201/200) before the app is actually launched — a background reconcile does the launching. A 2xx means "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).

FieldTypeRequiredDefaultNotes
namestringyesHuman-readable label.
exe_pathstringyesAbsolute path to the executable, or a URL for the kiosk presets.
presetenumno"generic"One of Preset.
argsstringno""Command-line arguments. Supports template variables.
working_dirstring | nullnonullWorking directory. Ignored by the kiosk presets.
placementenumno"fullscreen"One of Placement.
custom_rectCustomRect | nullnonullRequired geometry when placement is "custom".
launch_at_startupbooleannotrueLaunch on boot / reconcile. false = stored but not auto-launched.
watchdogbooleannotrueRestart the process if it exits.
max_restartsintegerno5Crash-loop threshold: more exits than this within ~60s → crash-looping.
launch_delay_sintegerno0Seconds to wait before launching.
note

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.

FieldTypeNotes
x_pctnumberLeft edge, % of monitor width.
y_pctnumberTop edge, % of monitor height.
w_pctnumberWidth, % of monitor width.
h_pctnumberHeight, % 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 limit

Liveness probe. No auth, no origin check, no rate limit.

curl -sk https://127.0.0.1:31077/api/pub/v1/health

GET /openapi.json

ungated

The OpenAPI 3 document for the public API, generated from the server code. Use it to generate typed clients.

warning

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-limited

List 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).

curl -sk https://127.0.0.1:31077/api/pub/v1/monitors
FieldTypeNotes
idstringStable monitor id — use it in the command endpoints.
labelstringUser label, else the detected display name.
connectedbooleanWhether the panel is currently attached.
primarybooleanWhether it's the primary display.
width / heightinteger | nullLive resolution; null when disconnected.

GET /status

origin-gated rate-limited

Live status of every command process.

curl -sk https://127.0.0.1:31077/api/pub/v1/status
FieldTypeNotes
command_idstringThe command this status is for.
stateenumstopped · starting · running · crash_looping · waiting_for_monitor · failed.
pidinteger | nullOS process id while running, else null.
placedbooleanWhether the window was actually positioned on its monitor.
last_errorstring | nullMost recent error, if any.

POST /monitors/{id}/commands

origin-gated rate-limited

Append one command to a monitor. Returns the new command's id.

  • Path: id — a monitor id from GET /monitors. The monitor must exist in the store, but need not be currently connected.
  • Body: a single PubCommand.
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
}'

PUT /monitors/{id}/commands

origin-gated rate-limited

Replace 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, ... ] }.
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"
}
]
}'

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 with launch_at_startup: false is 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 /status to confirm a command actually reached running.

Error reference

StatusBodyWhen
400 Bad Requestframework defaultRequest 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 Typeframework defaultMissing/incorrect Content-Type on a body.
422 Unprocessable Entityframework defaultValid 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}'
Fleet config (admin API)

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.