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
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.
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
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
Suspendedand stays reattachable; the next attach also wakes the VM. - VM fork — every child inherits the parent’s non-
Exitedsessions under the samesessionId, seeded with the parent’s at-fork screen; output diverges per child from there. - Program exits — session goes to
Exited, stays visible inlist()for ~60s, andonExit(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.
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
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
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.
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).Exitedsessions 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) andSIGKILL(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).