---
title: "PTY Sessions"
description: "Open persistent interactive shells on a VM that survive WebSocket disconnects, VM suspends, and forks."
url: "/docs/vms/pty"
---

A PTY (pseudo-terminal) session is a long-lived interactive shell that lives inside the VM and that you can attach to, detach from, and reattach to over a WebSocket. Sessions survive client disconnects, VM suspends, and VM forks, letting agents drive interactive programs (REPLs, editors, package managers, debuggers) without re-spawning every command.

Use a PTY when `vm.exec()` is too coarse: the program needs an interactive terminal, you want to send keystrokes mid-run, you need scrollback across reconnects, or you're running a background server (a dev server, a file watcher) whose output you want to read later.


## Open A Session

```ts
const session = await vm.pty.open({
  cols: 120,
  rows: 30,
  onData: (bytes) => process.stdout.write(bytes),
  onExit: (code) => console.log("program exited with", code),
});

session.write("echo hello\n");
console.log(session.sessionId);
```

With no `exec`, the agent spawns the user's login shell (or `/bin/sh`). The session is backed by a real PTY, so the shell is interactive — prompt, line editing, job control — and an explicit `exec` runs interactively too (a REPL like `python3`, a TUI like `htop`). `cols` and `rows` default to 80 x 24. Keep `session.sessionId` if you might reattach later.


## Reattach Across Disconnects

Calling `.detach()` (or losing the network) leaves the program running in the VM. Reconnect with `attach({ sessionId })` and you get the current screen replayed plus live output from that point.

```ts
session.detach();

// later, or from a different process / machine
const rebound = await vm.pty.attach({
  sessionId: session.sessionId,
  onData: (bytes) => process.stdout.write(bytes),
});
```

`detach()` closes only the local handle; the server-side session keeps running. The original `session` object is unusable after detach — `write` / `resize` / `signal` throw, telling you to `vm.pty.attach({ sessionId })` to get a fresh handle.


## Send Input, Resize, Signal

```ts
session.write("ls -la\n");
session.write(new Uint8Array([0x03])); // Ctrl-C as raw byte
session.resize({ cols: 200, rows: 60 });
session.signal("SIGINT");
```

`write()` accepts `string` (UTF-8 encoded) or `Uint8Array`. `signal()` accepts `SIGINT` (delivered as Ctrl-C on stdin) and `SIGKILL` (terminates the session's root process).


## Behavior Across VM Lifecycle

PTY sessions are first-class with respect to the VM lifecycle:

- **VM suspend** (explicit or idle) — session transitions to `Suspended` and stays reattachable; the next attach also wakes the VM.
- **VM fork** — every child inherits the parent's non-`Exited` sessions under the same `sessionId`, seeded with the parent's at-fork screen; output diverges per child from there.
- **Program exits** — session goes to `Exited`, stays visible in `list()` for ~60s, and `onExit(code)` fires.
- **VM stop / kill** — sessions terminate, attached WebSockets are evicted.

The key invariant: **an attached PTY does NOT hold the VM open or block a lifecycle operation.** If the VM is suspended (idle or explicit) or torn down, attached clients are evicted with a clean WebSocket close; the session itself stays reattachable as long as the VM is reachable again.

If you need a VM to stay running while a PTY session is in use, configure `idleTimeoutSeconds: null` (or a long value) on `vm.start()`. The platform's idle monitor measures only VM network-interface traffic, so PTY input — and even `vm.exec()` — does not reset the timer. To keep the VM alive without disabling idle-suspend entirely, the program in the VM has to generate outbound network traffic (an HTTP request, etc.) on its own.


## Auto-Reconnect

The SDK reconnects transparently on transient WebSocket drops, including the eviction triggered when a VM suspends. The reattach also wakes the VM if needed, so callers rarely have to think about transient hiccups.

Defaults: 5 attempts, exponential backoff from 500ms doubling to 8s. Writes called during a reconnect attempt are queued and flushed once the new WebSocket opens. `onClose` fires only when the session is *terminally* closed — program exited, caller called `detach()`, or reconnect attempts were exhausted.

```ts
const session = await vm.pty.open({
  exec: "/bin/sh",
  reconnect: { maxAttempts: 10, baseDelayMs: 200 },
  onReconnecting: (attempt, max) => console.log(`reconnect ${attempt}/${max}`),
  onReconnect: () => console.log("reconnected"),
});

// or disable entirely:
const noRetry = await vm.pty.open({ exec: "/bin/sh", reconnect: false });
```

With `reconnect: false` the original `session` is dead the moment its WebSocket closes — capture `sessionId` ahead of time and call `vm.pty.attach({ sessionId })` to get a new session object.


## Inheritance On Fork

```ts
const session = await vm.pty.open({ exec: "/bin/sh" });
const parentSessionId = session.sessionId;
session.write("echo parent\n");

const { forks } = await vm.fork({ count: 2 });
for (const { vm: child } of forks) {
  // Every fork has the parent's session under the same session_id,
  // seeded with the parent's at-fork screen.
  const childSession = await child.pty.attach({ sessionId: parentSessionId });
  childSession.write("echo from-fork\n");  // diverges from parent here
}
```

Writes on a fork's session go to that fork's process only; output never crosses between siblings or parent. Sessions that had already `Exited` in the parent are not inherited.


## List And Close

```ts
const { sessions } = await vm.pty.list();
for (const s of sessions) {
  console.log(s.sessionId, s.exec, s.running, s.suspended, s.attachedCount);
}

await vm.pty.close({ sessionId });
```

`Running`, `Suspended`, and recently-`Exited` sessions stay in `list()` for ~60 seconds after exit so reattach can read the final exit code. `close()` kills the underlying program (SIGKILL semantics) and removes the session.


## Multi-User Isolation

If the VM is configured with linux users, PTY sessions are scoped per user. A session opened with `vm.user("alice").pty.open()` runs as `alice`. A `vm.user("bob")` caller does not see Alice's sessions in `list()` and gets `403 SessionUserMismatch` if it tries to attach or close one.

A plain `vm.pty.list()` (with no `vm.user(...)`) is the VM owner and sees every session on the VM, including user-scoped ones.

```ts
const aliceSession = await vm.user("alice").pty.open({ exec: "/bin/sh" });

// from a Bob-authenticated client, list() does NOT show Alice's session.
const { sessions: bobView } = await vm.user("bob").pty.list();

// from the VM owner, list() shows everything.
const { sessions: ownerView } = await vm.pty.list();
```


## Caps And Limits

- **Sessions per VM:** 128. Going over the cap returns `SessionCapReached` (HTTP 429). `Exited` sessions count toward the cap until the 60-second retention window expires and the reaper clears them.
- **Scrollback:** ~10,000 lines per session, rendered ANSI-aware so the snapshot reproduces colors and cursor position.
- **Signals:** `SIGINT` (Ctrl-C on stdin) and `SIGKILL` (terminates the session). No other signals are exposed.
- **Errors:** pre-upgrade errors (auth, VM not running, session cap) come back as normal HTTP JSON. Errors after the WebSocket is open come back as RFC 6455 close codes (1008 / 1011).
