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:

ParameterMeaning
seq=NResume replay after sequence N (exclusive) — what a reconnecting client passes to continue exactly where it left off.
tail=NReplay 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:

  1. Server sends a ready control frame (text) carrying the process resource. SDK clients block on this before treating the stream as open.
  2. Server sends replay binary frames (per the resume hints), then live binary frames as output arrives.
  3. Server sends a terminal end control 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:

reasonMeaning
process_exitedThe 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_pausedFor 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_rebootedHard reset — the process is gone.
sandbox_deletedThe 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 group

name 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:

OffsetSizeFieldEncoding
08sequint64, big-endian
81streamuint8 — 1 = stdout, 2 = stderr, 3 = tty
98ts_msint64, big-endian (epoch milliseconds)
17dataraw 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).

On this page