> ## Documentation Index
> Fetch the complete documentation index at: https://docs.runloop.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# PTY Sessions

> Open an interactive pseudo-terminal on a Devbox for TUIs, REPLs, and shells that need a real TTY

<Tip>Start with the [Runloop Quickstart](/docs/tutorials/quickstart) to use the examples below.</Tip>

## 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.

<Note>
  For non-interactive command execution where you do not need a TTY, prefer [Execute Commands](/docs/devboxes/execute-commands) or [Named Shells](/docs/devboxes/named-shells). PTY sessions are heavier and are intended for true interactive use.
</Note>

## 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.

<CodeGroup>
  ```python Python theme={null}
  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
  ```

  ```typescript TypeScript theme={null}
  import Runloop from '@runloop/api-client';

  const client = new Runloop();

  const session = await client.pty.connect('demo');
  console.log(session.connect_url);        // WebSocket path for terminal I/O
  console.log(session.idle_ttl_seconds);   // how long the session is retained when idle
  console.log(session.cols, session.rows); // current terminal size
  ```
</CodeGroup>

### 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).

<CodeGroup>
  ```python Python theme={null}
  session = client.pty.connect(session_name="demo", cols=120, rows=40)
  ```

  ```typescript TypeScript theme={null}
  const session = await client.pty.connect('demo', { cols: 120, rows: 40 });
  ```
</CodeGroup>

## 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.

<CodeGroup>
  ```python Python theme={null}
  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())
  ```

  ```typescript TypeScript theme={null}
  import WebSocket from 'ws';
  import Runloop from '@runloop/api-client';

  const client = new Runloop();
  const session = await client.pty.connect('demo', { cols: 120, rows: 40 });

  const ws = new WebSocket(`wss://${API_HOST}${session.connect_url}`, {
    headers: { Authorization: `Bearer ${API_KEY}` },
  });

  ws.on('open', () => ws.send(Buffer.from('echo hello from pty\n')));
  ws.on('message', (data: Buffer) => process.stdout.write(data));
  ```
</CodeGroup>

### 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.

<CodeGroup>
  ```python Python theme={null}
  client.pty.control(
      session_name="demo",
      action="resize",
      cols=160,
      rows=50,
  )
  ```

  ```typescript TypeScript theme={null}
  await client.pty.control('demo', {
    action: 'resize',
    cols: 160,
    rows: 50,
  });
  ```
</CodeGroup>

### 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.

<CodeGroup>
  ```python Python theme={null}
  # Interrupt the foreground process (like pressing Ctrl-C).
  client.pty.control(
      session_name="demo",
      action="signal",
      signal="SIGINT",
  )
  ```

  ```typescript TypeScript theme={null}
  // Interrupt the foreground process (like pressing Ctrl-C).
  await client.pty.control('demo', {
    action: 'signal',
    signal: 'SIGINT',
  });
  ```
</CodeGroup>

<Tip>
  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.
</Tip>

### 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.

<CodeGroup>
  ```python Python theme={null}
  client.pty.control(session_name="demo", action="close")
  ```

  ```typescript TypeScript theme={null}
  await client.pty.control('demo', { action: 'close' });
  ```
</CodeGroup>

## 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](/docs/devboxes/named-shells) when you want stateful shell sessions for sequential command execution without the cost and complexity of a real terminal. Prefer [Execute Commands](/docs/devboxes/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.                                                                        |
