PLATINUM DOCS

Managed processes

Start long-running processes that outlive the request, then reconnect to them by handle.

Start a process that keeps running after the call returns, then reconnect to it later by handle. Use managed processes for servers, builds, watchers, and REPLs. Use exec for one-shot commands that finish inside the request.

Start a process

POST /v1/sandboxes/:id/processes starts the process and returns immediately with a handle. The process is not killed when the request ends.

curl -X POST "$PT_API_URL/v1/sandboxes/$ID/processes" \
  -H "Authorization: Bearer $PT_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"cmd": ["python3", "-m", "http.server", "8000"], "cwd": "/workspace"}'

The response is 201 {"handle": "p_a1b2c3d4e5f60718", "pid": 142, "pty": false}. The handle is opaque. Pass it back on every other route.

FieldDefaultNotes
cmdrequiredAn argv array runs with execve. A string runs with sh -c.
cwd/Must exist. .. is refused, not resolved.
env{}Merged over the sandbox's envVars. Request values win.
ptyfalseAllocates a PTY. See PTY processes.
cols, rows80, 24PTY only. 1-1000.
timeout_ms00 means no deadline. Above 0, the process group is SIGKILLed after that long and the record is marked timed_out. Max 24 h.

The deadline is enforced inside the VM, not by the HTTP request.

That is why timeout_ms here goes to 24 hours while exec stops at 300 s: nobody has to hold a connection open for it. A proxy that cuts your request cannot kill your process.

List and inspect

GET /v1/sandboxes/:id/processes returns every record the sandbox holds, including processes that already exited but are still inside the retention window.

curl "$PT_API_URL/v1/sandboxes/$ID/processes" -H "Authorization: Bearer $PT_TOKEN"
{"procs": [{
  "handle": "p_a1b2c3d4e5f60718", "pid": 142, "cmd": ["python3", "-m", "http.server", "8000"],
  "started_at": 1754650000, "running": true, "exit_code": 0, "exited_at": 0,
  "pty": false, "timed_out": false, "stdout_len": 4096, "stderr_len": 0
}]}

exit_code means nothing while running is true. stdout_len and stderr_len are lifetime byte counts, so they keep growing past the buffer size.

How the process died

A process killed by a signal reports both exit_code: 128+N and an explicit signal: N:

{"handle": "p_a1b2...", "running": false, "exit_code": 143, "signal": 15, "exited_at": 1754650009}

signal appears only for a signal death, and is absent otherwise. Absent and 0 would mean the same thing — 0 is not a signal any process can be killed by — so omitting it loses nothing and keeps "is signal there?" a straight answer to "was it killed?".

exit_code alone cannot tell you how a process died.

A process killed by SIGTERM and a process that called exit(143) both report exit_code: 143. signal is the field that separates them, and that is why it exists — saving you the 128+N arithmetic is the smaller half of it.

The record routes, /logs reply, and stream exit event carry it, so a streaming client can distinguish a signal death without a second request.

GET /v1/sandboxes/:id/processes/:handle returns one record. Add ?wait_ms=N to block server-side until the process exits or N ms pass (max 55000, below the 60 s HTTP edge). SDKs spend longer waits as consecutive slices. The reply carries wait_timed_out: true means the wait gave up and the process is still running. The record's own timed_out means the process hit its timeout_ms and was killed. They are different fields on purpose.

curl "$PT_API_URL/v1/sandboxes/$ID/processes/$HANDLE?wait_ms=55000" \
  -H "Authorization: Bearer $PT_TOKEN"

Read output by offset

GET /v1/sandboxes/:id/processes/:handle/logs reads from the process's output buffers. Offsets are absolute byte counts since the process started, not positions in a buffer.

curl "$PT_API_URL/v1/sandboxes/$ID/processes/$HANDLE/logs?stdout_offset=0&limit=65536" \
  -H "Authorization: Bearer $PT_TOKEN"
{"handle": "p_a1b2...", "stdout": "Serving HTTP on 0.0.0.0 port 8000\n", "stderr": "",
 "encoding": "utf8", "stdout_offset": 34, "stderr_offset": 0,
 "stdout_dropped": 0, "stderr_dropped": 0, "running": true, "exit_code": 0}

Send the stdout_offset and stderr_offset you were given back on the next call and you continue at exactly the next byte. Starting from 0 replays whatever the buffer still holds. This is what makes a dropped connection recoverable: your client crashes, restarts, sends its last offset, and loses nothing.

?encoding=base64 returns the exact bytes instead of UTF-8 text. Use it for anything that is not a log line. Text decoding replaces every invalid byte sequence with U+FFFD, which is harmless in a log and destructive in a tarball.

Dropped bytes are a hole, not a statistic

Each stream has a bounded ring buffer (256 KiB by default). When a process out-writes the buffer, or a reader falls too far behind, the oldest bytes are evicted.

Non-zero stdout_dropped means the returned data does not start where you asked.

It starts at offset + dropped, and the bytes in between are gone. A UI that concatenates across that gap is showing a log that never existed. Surface the gap, or read more often, or raise PT_PROC_BUF_BYTES.

The trade is deliberate. A slow reader costs bytes, never guest memory, so a chatty server nobody is polling cannot OOM the VM.

Stream output

GET /v1/sandboxes/:id/processes/:handle/stream is a Server-Sent Events tail. The control plane polls inside the VM and pushes what it finds, so you hold one connection instead of running your own poll loop across the internet.

curl -N "$PT_API_URL/v1/sandboxes/$ID/processes/$HANDLE/stream" \
  -H "Authorization: Bearer $PT_TOKEN"
event: stdout
data: {"type":"stdout","data":"building...\n","encoding":"utf8","offset":12}

event: exit
data: {"type":"exit","exit_code":0,"stdout_offset":12,"stderr_offset":0}
EventMeaning
stdout, stderrA chunk. offset is the absolute offset of the byte after it, so it is exactly what you hand to /logs?stdout_offset= to resume. dropped appears when bytes were evicted.
exitThe process exited and both streams are drained. The stream ends. Carries exit_code and, for a signal death, signal.
errorContact with the in-VM agent was lost. The stream ends.
endThe duration cap was reached. reason says why. Reconnect from the offsets it reports.
: keepaliveA comment, roughly every 15 s while idle, so proxies do not time the connection out.

Query parameters: stdout_offset and stderr_offset to resume, encoding=base64 for exact bytes, and max_seconds to cap the stream (5-1800, default 1800). The poll interval starts at 100 ms while output is flowing and backs off to 1 s when the process goes quiet.

Write to stdin

POST /v1/sandboxes/:id/processes/:handle/stdin writes to the process's stdin.

curl -X POST "$PT_API_URL/v1/sandboxes/$ID/processes/$HANDLE/stdin" \
  -H "Authorization: Bearer $PT_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"data": "print(2+2)\n"}'

{"close": true} closes the pipe after the write, which is the only way to make an EOF-terminated reader such as sort or cat finish. data may be empty, so "just close it" is one call. Writing after a close fails with stdin_closed. encoding is utf8 (default) or base64. One call carries at most 1 MiB; send more in chunks.

Signal and kill

POST /v1/sandboxes/:id/processes/:handle/signal takes a number or a name ("SIGTERM", "TERM", 15 are all the same signal).

curl -X POST "$PT_API_URL/v1/sandboxes/$ID/processes/$HANDLE/signal" \
  -H "Authorization: Bearer $PT_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"signal": "SIGTERM"}'

Signals go to the whole process group by default, which is what actually stops a shell that spawned children. {"group": false} targets only the direct child; the work it started keeps running, reparented to PID 1.

Do not daemonize with setsid inside a managed process. A child that creates a new session has deliberately left the handle's process group, so POSIX group signals cannot reach it without a per-process cgroup. Platinum disconnects any inherited output pipe after the leader's one-second drain grace so that detached child cannot keep the stream open forever, but the child itself can survive until the VM stops. Start the long-lived program directly instead.

DELETE /v1/sandboxes/:id/processes/:handle is SIGKILL to the group. The record is retained, not deleted, so you can still read the final logs and the exit code afterwards. That makes it safe to call when you are not sure whether the process already finished, and a second call is still 200.

Run under a PTY

{"pty": true} on start allocates a pseudo-terminal. Programs that check isatty then behave interactively: colour, progress bars, prompts, line editing.

Two consequences. stdout and stderr are inherently merged, so all output arrives on stdout and stderr is always empty. And stdin goes to the PTY master, so the stdin route is also how you deliver keystrokes and control characters. Send control bytes with encoding: "base64"^C is "Aw==" and ^D is "BA==", neither of which survives being typed into a JSON string as text.

POST /v1/sandboxes/:id/processes/:handle/resize changes the window size. Send it whenever the user's terminal changes size, or full-screen programs will draw at the wrong dimensions.

curl -X POST "$PT_API_URL/v1/sandboxes/$ID/processes/$HANDLE/resize" \
  -H "Authorization: Bearer $PT_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"cols": 120, "rows": 40}'

Resizing a process that was not started with pty: true fails with not_a_pty. The terminal WebSocket is the other PTY path; it is fixed at 80×24 and dies with the connection, so prefer a PTY process for anything a user resizes or reconnects to.

Lifetime and retention

EventWhat happens to a managed process
The starting request returnsNothing. It keeps running.
Pause and resumeIt survives. The registry lives in guest memory, which is what the memory snapshot captures.
Auto-stopA live process does not count as user activity, so the idle policy can stop the sandbox. A normal memory-backed resume continues it; a disk-only/cold fallback loses it. Keep polling/streaming, extend the policy, or disable auto-stop for an unattended job.
Memory-backed stop and resumeIt survives with the rest of guest RAM.
Disk-only restore, cold boot, or recreationIt is gone. The new agent has an empty registry and old handles 404.
The process exitsThe record is kept for 600 s with its exit code and its final output, then garbage-collected.
Sandbox deleteEverything goes with the VM.

A handle is unique per agent boot, so a 404 from a handle you believe in means either the retention window passed or the VM came back through a cold/disk-only path.

Limits

LimitValueOverride
Live processes per sandbox64PT_PROC_MAX
Output buffer per stream256 KiBPT_PROC_BUF_BYTES
Retention after exit600 sPT_PROC_RETAIN_SEC
Bytes per /logs call64 KiB default, 4 MiB maxlimit query
Bytes per stdin call1 MiB5 s guest-side write deadline
timeout_ms24 h
wait_ms55 s per requestSDKs slice longer waits
Stream duration30 minmax_seconds query

Exited records are reclaimed before a start is refused, so hitting the ceiling means 64 genuinely-running processes. These routes share one rate-limit bucket with exec: they travel the same channel into the VM, so alternating verbs does not buy extra budget.

Errors

CodeStatusMeaning
managed_procs_unsupported409The sandbox's in-VM agent predates managed processes. See below.
process_not_found404No such handle: garbage-collected, or from before the last VM stop.
process_not_running409The process already exited. Read its logs and exit code instead.
process_limit_reached40964 live processes. Kill one or wait.
not_a_pty409Resize or terminal input against a non-PTY process.
stdin_closed409A write after close: true.
guest_timeout504No answer from the agent. The operation may still have taken effect — list the processes to find out.
guest_unreachable502The agent could not be reached at all.

A 409 managed_procs_unsupported is not a broken sandbox.

It is a healthy guest running an agent built before these verbs existed. exec, files, and the terminal all keep working on it. Rebuild the template and the sandboxes you create from it will have an agent that supports these routes.

The examples on this page are REST because that is the surface every client shares, and the shapes above are the full contract. For wrapper methods, see the TypeScript and Python references.

See also