Concepts
The model behind the API: how trust, security, long-running work, events and errors fit together. Full details per command live in docs/API.md; the internals are in docs/CONTROL-ARCHITECTURE.md.
Transports and the trust model
The protocol is NDJSON everywhere: one JSON object per line, requests correlated by an echoed id, protocol version 1 (reported by launcher.version).
| Transport | Availability | Trust |
|---|---|---|
Unix domain socket <launcher_dir>/api.sock, mode 0600 |
macOS, Linux | Fully trusted: no auth, all scopes, no path restrictions. Any same-user process has full control of the launcher. |
Windows named pipe \\.\pipe\pandora-launcher-api (default DACL: creating user + administrators, local only) |
Windows | Same full trust as the Unix socket. |
| TCP, plus RFC6455 WebSocket on the same port | Opt-in via env vars, off by default | Authenticated with scoped tokens, scope-checked and path-jailed on every request. No TLS: loopback or an encrypted tunnel only. |
A handful of host-control and SSRF-capable commands are local-socket only and refused over any network transport regardless of token scope: auth.mint_token / auth.list_tokens / auth.revoke_token, launcher.quit, launcher.install_update, hooks.register, hooks.delete, and servers.status (an arbitrary-address pinger). So the network can never bootstrap its own credentials, install code, or port-scan through the launcher.
Configuration is by environment variables, currently named PANDAPI_API_TCP, PANDAPI_API_WS, PANDAPI_API_NO_AUTH and PANDAPI_API_PATH_ALLOW (PANDAPI_* aliases are planned; the PANDAPI_API_* names stay authoritative). See Network-Mode-and-Security.
Scopes
Each token carries a set of scopes. Commands map to a required scope through a per-command read-only allowlist: only side-effect-free, no-egress, non-host-affecting commands require read; everything else defaults to write. The default is fail-safe: a newly added command is never silently reachable by a read token.
| Scope | Covers |
|---|---|
read |
Observe-only commands (listings, logs, metrics, state.export, api.describe, events.subscribe, ops.status/wait, ...) |
write |
Everything not in the read allowlist: instance/content/world/server mutations, uploads, game.capture, ops.cancel, imports, ... A write token also satisfies read. |
accounts |
Account mutations (accounts.* except accounts.list, which needs only read) |
settings |
settings.* and sync.* writes (settings.get / sync.state need only read) |
* |
Everything |
The path jail
For network clients, every path parameter must canonicalize under one of the PANDAPI_API_PATH_ALLOW roots, or the request fails with path_denied. Unset or empty means every path parameter is denied. This covers log paths, export outputs, import files and roots, dev-link globs, skin files, world-backup zips, nested file.path entries in content.install / content.resolve / content.sync, and icon.path.
On top of the jail, the process-execution vectors wrapper_command, linux_wrapper, system_libraries, jvm_binary and jvm_flags are refused outright over the network on any command (including the instances.set fields that instances.ensure forwards), independent of the allowlist. A network client can drive the launcher; it cannot make the launcher execute arbitrary programs.
The local socket is exempt from all of this: it is fully trusted by design.
Async operations
Commands that start long-running work (instances.start, content.install, content.update, content.sync, content.download_children, instances.export / import_file / update_check / snapshot / restore, import.run, accounts.login / reauth, logs.upload, launcher.install_update) return {"op": N} immediately. An op is the same ModalAction structure the UI uses for its progress modals, reified over the wire:
| Field | Meaning |
|---|---|
finished |
Completed, successfully or not |
error |
Error message or null. An op can finish with error set: treat that as failure. |
visit_url |
{"message", "url"} when blocked on user interaction (Microsoft login, mclo.gs result); clears when done |
progress |
Trackers {"title", "count", "total", "done"}, appearing per phase (asset downloads, library verification, ...) |
cancel_requested |
Set by ops.cancel; cancellation is cooperative |
age_secs, label |
Seconds since registration; the command that created the op |
Consume ops three ways: poll ops.status (about 1 s intervals), block with ops.wait {op, timeout_secs} (internally polls every 300 ms), or subscribe to the op.started / op.progress / op.finished events. instances.start {wait: "running"|"exited"} is a built-in convenience that blocks until the game reaches the target state.
Op ids are process-local and monotonic; finished ops are pruned after one hour; after a launcher restart old op ids return unknown op. A change in session_id (returned by launcher.version, launcher.status and the subscribe ack) signals such a restart.
Event stream v2
events.subscribe turns on event delivery for the current connection. Event lines carry a monotonically increasing seq (starting at 1, process-lifetime, reset on restart) next to event and data; they never carry an id, which is how you distinguish them from responses on a shared connection.
- Replay: the launcher keeps the most recent 4096 events in a replay ring. Subscribing with
{"since_seq": N}first replays every buffered event withseq > N, then continues live, so a reconnecting client loses nothing still in the ring. - Filters:
{"types": [...]}and{"instance": "index:generation"}filter server-side, applying to both replay and the live stream. - Lag: live delivery fans out from a bounded broadcast buffer (capacity 512). A slow consumer receives
{"event": "events.lagged", "data": {"missed": N}}instead of stalling the sender; re-subscribe withsince_seqto replay the gap. - Re-subscribing on the same connection replaces the previous filters/
since_seq; there is no unsubscribe (close the connection).
The 19 event types fall into three families: launcher/account state mirrored from the UI, op lifecycle, and live game events, including the game.output console tee (opt-in via game.capture or the GUI game-output setting; capture is decided at launch time) and best-effort semantic events parsed from the game log (game.ready, game.crash, game.server_connected, ...). Full list in Command-Reference.
Structured errors
Every ok: false response carries {"code", "message", "retryable"}. Branch on code; retry when retryable is true.
| Code | Retryable | Raised for |
|---|---|---|
not_found |
no | Unknown instance, account, op or resource |
invalid_params |
no | Missing/malformed parameter, bad enum/uuid, out-of-range bounds, already-existing target |
unknown_command |
no | No such command |
timeout |
yes | A bounded wait elapsed (log list, caches, ops.wait, backend ping, ...) |
not_ready |
yes | A watcher-backed cache has not finished loading; retry shortly |
upstream_failed |
yes | Modrinth/CurseForge lookups, and local file operations (clone/backup/restore/snapshot) |
backend_unavailable |
yes | The backend message loop dropped the request |
unauthorized |
no | Network: authenticate first, or the token is unknown/revoked/expired/bad |
forbidden |
no | Network: missing scope, or a local-socket-only command |
path_denied |
no | Network: path outside the allowlist, or an execution-vector field set |
error |
no | Anything not classified above |
Addressing, consistency, connections
- Names are stable, ids are ephemeral. Instance and content ids are slab addresses (
"index:generation") valid only for the current process; a restart or delete+recreate changes them. Script againstname(matched case-insensitively); treatidas a per-session handle; re-read content ids before each batch. - Fire-and-forget mutations.
{"requested": true}means enqueued, not applied. State persists asynchronously (usually within a second); verify with the corresponding read or by watching events. - Eventually consistent listings.
instances.worlds/servers/contentare watcher-backed caches: each call triggers a reload and polls up to 10 seconds; onnot_ready, retrying is correct. - Sequential per connection. Requests on one connection are processed in order; a slow command delays later requests on the same connection. Open multiple connections for concurrency; event delivery is not blocked by an in-flight command. A few op-returning commands (
content.download_children,logs.upload,import.run) run inline on the backend loop and briefly block other commands while they work. - Unknown
paramskeys are ignored, never an error.