Process streaming protocol
The WebSocket wire format for streaming a sandbox process's output and driving an interactive session — for raw-API clients.
Most users never touch this: the CLI and TypeScript SDK speak this protocol for you. This page is for building your own client against the raw WebSocket endpoint.
The HTTP API covers starting, listing, stopping, and signalling processes as plain JSON. Live output and interactive I/O don't fit request/ response, so they run over a single WebSocket route on the sandbox access gateway, documented here (it has no OpenAPI schema — message-level framing isn't expressible there).
Getting a stream URL
You never construct the WebSocket URL. Mint one from the HTTP API, which returns a complete, short-lived, single-use URL with the token embedded:
PUT /v1/sandboxes/{sandboxId}/processes/{ref}/stream-access
Content-Type: application/json
{ "mode": "logs" }// 200
{
"url": "wss://{gateway-host}/v1/sandbox-process-stream?token=tok_…",
"token": "tok_…",
"expires_at": "2026-07-06T09:01:00Z"
}mode is logs (read-only) or attach (read-write); it is baked into the
token and enforced by the gateway, so a logs token can't be replayed to inject
input. The sandbox, process, and mode all live inside the token — they are never
URL parameters. Connect to the returned url before it expires.
Query parameters
The minted URL may be extended with two optional resume hints:
| Parameter | Meaning |
|---|---|
seq=N | Resume replay after sequence N (exclusive) — what a reconnecting client passes to continue exactly where it left off. |
tail=N | Replay only the last N lines before going live. |
Defaults with neither set: logs replays the full retained log; attach
replays a screenful. If a client's seq was already evicted from the capped log
store, replay resumes from the oldest retained frame and the gap is flagged.
Message flow
The connection carries two WebSocket message kinds:
- Text frames — JSON control messages, discriminated by a
"type"field. - Binary frames — raw byte payloads (log/output data down; stdin up).
The sequence is always:
- Server sends a
readycontrol frame (text) carrying the process resource. SDK clients block on this before treating the stream as open. - Server sends replay binary frames (per the resume hints), then live binary frames as output arrives.
- Server sends a terminal
endcontrol frame, then closes. The end reason is mirrored in the WebSocket close reason, so close-only clients still learn why the stream ended.
A plain network drop produces no end frame — that's how a client tells
"connection broke, reconnect with seq" apart from a deliberate end.
Control frames (text, JSON)
ready — first server frame
{
"type": "ready",
"process": {
"id": "01J9F3ZK7QW4B2Y8N5T1QAZ6RX",
"name": "dev-server",
"command": "npm run dev",
"status": "running",
"exit_code": null,
"started_at_ms": 1751792400000,
"tty": { "cols": 120, "rows": 30 }
},
"logging_disabled": false
}Empty/unset fields are omitted, not sent as "" or null. For a running
process that means signal, finish_reason, finished_at_ms, and
restarted_from are absent; they appear only once set (e.g. a finished process
carries finish_reason and, if it ended on a signal, signal). exit_code is
always present — null while running, the integer code once exited. name,
command, and tty are absent when the process has no name, no command, or is
pipe-mode.
logging_disabled is true when the process started with logging off
(max_log_bytes: 0). On an attach stream that's fine — no replay is sent and
live interaction proceeds. On a logs stream, requesting one is rejected up
front with 409 logging_disabled rather than opening an empty stream.
end — terminal server frame
{ "type": "end", "reason": "process_exited", "exit_code": 0 }
// signal-killed instead:
{ "type": "end", "reason": "process_exited", "signal": "SIGKILL" }exit_code and signal are present only when set — a clean exit carries
exit_code and omits signal; a signal-killed process carries signal.
Reasons:
reason | Meaning |
|---|---|
process_exited | The process finished — normal exit, timeout kill, or user stop. exit_code/signal describe how. An on_hibernate=stop process, finalized just before a hibernate snapshot, also ends this way. |
sandbox_paused | For a process still running at pause time (on_hibernate=preserve or restart_after_resume): the process is preserved in the RAM snapshot, not finished — reconnect after resume to keep tailing. A restart_after_resume process is replaced by a fresh successor on resume, so reconnect to it by name. |
sandbox_rebooted | Hard reset — the process is gone. |
sandbox_deleted | The sandbox was deleted — process and logs are gone. |
A stream may also close with a policy reason without a normal end:
slow_consumer (a client fell too far behind and was dropped rather than
allowed to backpressure the process) or client_data_on_logs (a read-only
logs client sent an upstream data frame).
sandbox_stopped is reserved in the protocol for a future stopped state but is
never emitted today — Tektona has no stopped state distinct from pause.
Client → server (attach only)
On an attach stream the client may send control frames:
{ "type": "resize", "cols": 200, "rows": 50 } // resize the PTY
{ "type": "signal", "name": "SIGINT" } // deliver a signal to the groupname accepts the standard signal set (SIGHUP, SIGINT, SIGQUIT,
SIGTERM, SIGKILL, SIGUSR1, SIGUSR2, SIGSTOP, SIGCONT), with or
without the SIG prefix. Resize and signal also exist as REST endpoints for
detached control; both hit the same backend path.
Binary frames
Server → client: log/output data
Every down binary frame is a fixed 17-byte header followed by the raw payload bytes:
| Offset | Size | Field | Encoding |
|---|---|---|---|
| 0 | 8 | seq | uint64, big-endian |
| 8 | 1 | stream | uint8 — 1 = stdout, 2 = stderr, 3 = tty |
| 9 | 8 | ts_ms | int64, big-endian (epoch milliseconds) |
| 17 | … | data | raw bytes |
┌──────────────┬────────┬──────────────┬───────────────┐
│ seq (u64 BE) │ stream │ ts_ms (i64 BE)│ data … │
│ 8 bytes │ 1 byte │ 8 bytes │ remainder │
└──────────────┴────────┴──────────────┴───────────────┘Terminal consumers (e.g. xterm.js) slice off the 17-byte header and write the
tail straight to the terminal. Non-TTY processes separate stdout (1) and
stderr (2); TTY processes emit a single combined tty (3) stream.
seq is a monotonic per-process sequence number assigned when the frame is
written. Replay, live tailing, and reconnect are all keyed on it — never on file
offsets — so evicting old frames from the capped store can't desync a follower:
a reconnecting client passes its last-seen seq and resumes exactly there.
Client → server: stdin (attach only)
Up binary frames are raw stdin bytes with no header — write them straight to
the socket. They are ignored on a logs stream (and trip the
client_data_on_logs close).