Overview
A PTY (pseudo-terminal) session gives you an interactive, line-disciplined shell on a Devbox — the same kind of terminal you would get from ssh. PTY sessions are the right primitive when a program needs to behave as if it is attached to a real terminal: full-screen TUIs (vim, htop, less), interactive REPLs that detect a TTY, anything that responds to window-size changes, and anything that needs to receive signals like SIGINT or SIGWINCH.
A PTY session has two surfaces:
- Control plane (HTTP) — bootstrap or reconnect to a session, resize the terminal, send signals, and close the session. Available through the SDK.
- Data plane (WebSocket) — the interactive byte stream. Raw binary frames in both directions. Connect directly with a WebSocket client.
For non-interactive command execution where you do not need a TTY, prefer Execute Commands or Named Shells. PTY sessions are heavier and are intended for true interactive use.
Connecting to a session
pty.connect looks up a session by session_name and either reconnects to it or creates it. A newly created session starts an interactive bash shell on the Devbox. The response includes connect_url — a server-relative path to the WebSocket data plane.
from runloop_api_client import Runloop
client = Runloop()
session = client.pty.connect(session_name="demo")
print(session.connect_url) # WebSocket path for terminal I/O
print(session.idle_ttl_seconds) # how long the session is retained when idle
print(session.cols, session.rows) # current terminal size
Session names
session_name is client-chosen — it is an identifier you pick, not an opaque server-issued ID. It must:
- Be 1–256 characters long
- Use only ASCII letters, digits,
-, and _
Reusing the same name reconnects to the same logical PTY session as long as it is still alive. After the idle TTL expires, after an explicit close, or after a Devbox lifecycle event replaces the PTY process (such as suspend/resume), the next connect with that name starts a fresh shell.
Initial terminal size
You can request an initial terminal size at connect time with the cols and rows query parameters. Both must be present and in the range 1–1000; otherwise they are ignored and the session uses the defaults (80×24).
session = client.pty.connect(session_name="demo", cols=120, rows=40)
Streaming terminal I/O
The interactive terminal stream is exchanged over a WebSocket at the path returned in connect_url. The protocol is intentionally simple:
- Frames are raw binary in both directions.
- Bytes the client sends are written to the PTY master (keystrokes, paste, control characters).
- Bytes the server sends are bytes the shell wrote (stdout/stderr from the PTY slave side).
Use any WebSocket client to attach. The example below uses Python’s websockets library; the same pattern applies in any language.
import asyncio
import websockets
from runloop_api_client import Runloop
client = Runloop()
session = client.pty.connect(session_name="demo", cols=120, rows=40)
# Construct the absolute WebSocket URL from the API host + connect_url.
ws_url = f"wss://{API_HOST}{session.connect_url}"
async def stream():
async with websockets.connect(
ws_url,
additional_headers={"Authorization": f"Bearer {API_KEY}"},
) as ws:
await ws.send(b"echo hello from pty\n")
async for frame in ws:
# frame is `bytes` — write straight through to your terminal.
print(frame.decode(errors="replace"), end="", flush=True)
asyncio.run(stream())
Single-attach contract
Only one WebSocket client may be attached to a session at a time. A second concurrent attach is rejected at WebSocket upgrade time with HTTP 400. The connect control call itself always succeeds for a valid session_name, even if another client is currently attached — single-attach is enforced when you actually open the WebSocket.
Close codes
When the server closes the WebSocket it uses application-defined close codes so the client can distinguish reasons:
| Code | Meaning |
|---|
4000 | The underlying shell exited (the PTY process is gone). |
4001 | Ping timeout — the server did not receive a frame within the liveness window. |
Browsers automatically respond to WebSocket pings and will not normally trip 4001. If you are writing a non-browser client, make sure your WebSocket library handles ping frames or sends periodic traffic.
Disconnect does not terminate the session
Closing the WebSocket does not terminate the PTY session. The session is retained for idle_ttl_seconds, so a later pty.connect using the same session_name resumes the same shell with its environment, working directory, and running processes intact. After the TTL expires the next connect creates a fresh shell.
Controlling a session
The control endpoint applies operations to an existing session. It accepts an action field plus the parameters that action requires.
Resize
Tell the PTY about a new terminal size. Both cols and rows are required and must each be in the range 1–1000. The new winsize is applied to the PTY master and the kernel delivers SIGWINCH to the foreground process group, so programs like vim redraw correctly.
client.pty.control(
session_name="demo",
action="resize",
cols=160,
rows=50,
)
Signal
Deliver a POSIX signal to the slave’s foreground process group via killpg(2). Pass the signal name as a string — for example SIGINT, SIGTERM, SIGHUP, SIGUSR1. Unknown names return 400. If the shell has already exited and there is no foreground process group, the call returns 400.
# Interrupt the foreground process (like pressing Ctrl-C).
client.pty.control(
session_name="demo",
action="signal",
signal="SIGINT",
)
You can also send control characters directly through the WebSocket (e.g. writing \x03 for Ctrl-C). Use the signal action when you want to deliver a specific POSIX signal independent of what the terminal happens to be in at the moment.
Close
Terminate the session. Sends SIGHUP to the foreground process group (best-effort; ignored if the shell has already exited) and drops the session from the server’s session cache. A later pty.connect with the same session_name will create a fresh PTY session.
client.pty.control(session_name="demo", action="close")
When to use PTY sessions
Use a PTY session when:
- A program detects whether stdin/stdout is a TTY and behaves differently when it is (
python -i, node, many language REPLs).
- You want to drive a full-screen TUI (
vim, nano, htop, less, k9s).
- You need real signal semantics —
Ctrl-C interrupting the foreground process group, SIGWINCH on resize.
- You are building an in-browser terminal or any human-facing shell.
Prefer Named Shells when you want stateful shell sessions for sequential command execution without the cost and complexity of a real terminal. Prefer Execute Commands for one-off, non-interactive commands.
Quick reference
| Concept | Behavior |
|---|
| Session naming | Client-chosen, 1–256 chars, [A-Za-z0-9_-]. |
| Default terminal size | 80 cols × 24 rows when cols/rows are omitted or invalid. |
Allowed cols/rows | Both required together, each in 1–1000. |
| Attach concurrency | One WebSocket client at a time. Second concurrent attach is rejected with HTTP 400 at upgrade. |
| Disconnect behavior | Session is retained for idle_ttl_seconds; reconnecting with the same session_name resumes the same shell. |
| Session replacement | TTL expiry, explicit close, or a Devbox lifecycle event (e.g. suspend/resume) replaces the underlying shell on the next connect. |
| Data plane | Raw binary WebSocket frames in both directions at connect_url. |
Close code 4000 | Shell exited. |
Close code 4001 | Ping timeout — no client frames within the liveness window. |