Freestyle Docs

Freestyle / Docs

PTY Sessions

Open interactive terminal sessions on a VM and reattach after disconnecting.

A PTY (pseudo-terminal) is an interactive process inside a VM. Use it for shells, REPLs, editors, package managers, and commands that need terminal semantics or need to run longer than vm.exec()’s five-minute limit.

PTY sessions live in the guest and outlive the WebSocket connection that opened them. Detaching leaves the process running so you can attach again.

Open A Session

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

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

Selecting a Linux user is optional. vm.pty uses the VM’s default user: the account with uid 1000, or root if the image has no such account.

Omit exec for a login shell, or start a specific interactive command:

const python = await vm.pty.open({
  exec: "python3",
  onData: (bytes) => process.stdout.write(Buffer.from(bytes)),
});

cols and rows default to 80 by 24. In Node, the SDK uses its bundled ws dependency so it can authenticate the WebSocket handshake.

Browser Clients

Create an identity on your server, grant access to the VM, and mint an access token. Use the VM’s actual ID. You can omit Linux-user settings from both the grant and the browser client:

import { Freestyle } from "freestyle";

const server = new Freestyle({ apiKey: process.env.FREESTYLE_API_KEY });
const { identity, identityId } = await server.identities.create();
await identity.permissions.vm.grant({ vmId });
const { id: tokenId, token: identityAccessToken } = await identity.tokens.create({
  expiresAt: new Date(Date.now() + 15 * 60_000).toISOString(),
});

Save identityId for your application user so later visits can reuse the identity. Return the access token only to that authenticated user through your backend; keep the account API key on the server. You can revoke a token with identity.tokens.revoke(tokenId). This token expires after 15 minutes. Omit expiresAt for a non-expiring token. Expiration blocks new requests and PTY reattachments; it does not disconnect an already-open terminal. Mint a fresh token through your backend when the user needs to reconnect after expiration. Expired tokens are automatically deleted in batches after a 24-hour retention period. Non-expiring tokens remain until explicitly revoked.

In the browser, use that token to connect as the VM’s default user:

const freestyle = new Freestyle({ identityAccessToken });
const pty = freestyle.vms.ref(vmId).pty;

With xterm.js

xterm.js provides the terminal display and keyboard input. Install @xterm/xterm alongside freestyle, then use the SDK adapter:

import { Terminal } from "@xterm/xterm";
import "@xterm/xterm/css/xterm.css";
import { connectXterm } from "freestyle";

const terminal = new Terminal({ allowProposedApi: true });
terminal.open(document.getElementById("terminal")!);
const connection = await connectXterm({ pty, terminal, slug: "terminal" });
// On unmount: connection.dispose(); terminal.dispose();

The adapter connects input/output and resize events, enables reconnection, and resets the display before replay so output is not duplicated. It also suppresses responses to recorded terminal queries, preventing stray characters from being typed at the shell prompt. New agents provide exact replay boundaries; older agents use a bounded quiet-window fallback. The SDK does not bundle xterm.js. Use xterm’s fit addon if you want the terminal to resize with its container.

This does not require upgrading an agent that already supports PTYs. Older agents ignore the optional replay flag; the adapter uses 150ms of quiet output with a two-second cap instead of exact replay markers. The existing lazy-update policy for agents that reject PTY operations as unsupported is unchanged.

The browser sends its identity token through Sec-WebSocket-Protocol, using freestyle-pty and freestyle-identity.<base64url-encoded-token> (without padding). The gateway selects freestyle-pty and applies the same identity permissions as header authentication.

Send Input, Resize, And Signal

session.write("ls -la\n");
session.write(new Uint8Array([0x03])); // Ctrl-C
session.resize({ cols: 200, rows: 60 });
session.signal("sigint");
session.signal("sigkill");

Signals use the lowercase values sigint and sigkill.

Detach And Reattach

Save the session ID before detaching. detach() closes only the current WebSocket; it does not terminate the guest process.

const sessionId = session.sessionId;
session.detach();

const attached = await vm.pty.attach({
  session: sessionId,
  onData: (bytes) => process.stdout.write(Buffer.from(bytes)),
  onExit: (code) => console.log("exited", code),
});

session takes the numeric ID, or the slug the session was opened with — name a session at open() and you can reattach to it from anywhere without storing the ID.

Enable reconnect: true on open() or attach() to keep the same session handle across dropped connections. Recovery reattaches by the learned session ID, preserves linuxUser, and resends the latest terminal dimensions. It never reruns exec or creates a replacement shell. If the initial handshake fails before the session ID arrives, the SDK rejects instead of retrying an ambiguous open; a stable slug lets you safely find that session again.

Retries use exponential backoff with jitter, from 400ms up to a 10-second base interval, for at most five minutes per outage. Customize these with reconnect: { timeoutMs, initialDelayMs, maxDelayMs }. Each connection must receive sessionInfo within connectTimeoutMs (15 seconds by default).

onStateChange reports connected, reconnecting, closed, or exited; session.state initially reports connecting. onClose fires when recovery ends, not for each retry. Process exit, permission failures, and a missing session stop recovery. detach() cancels pending work and leaves the shell running. session.reconnect() restarts an exhausted recovery attempt manually; browser clients also check on returning to a tab or coming back online.

write() and signal() throw while disconnected: input is never buffered or retried because the server may already have received it. resize() remembers the latest dimensions even while reconnecting.

Gateways with heartbeat support send application keepalives; the SDK detects a silent connection after missing them. With older gateways, it retries explicit disconnects but does not treat a quiet shell as a dead connection.

For a custom terminal integration, onReplayStart({ exact }) and onReplayEnd mark retained output, including replays during transparent manager restarts. Reset the display before replay and keep terminal-query responses muted until the renderer has parsed it. Exact end events require an updated manager and guest agent. A gateway fallback start reports exact: false and has no end event; connectXterm handles the compatibility fallback for you.

List And Close

const { sessions } = await vm.pty.list();

for (const session of sessions) {
  console.log(session.sessionId, session.state, session.linuxUser, session.cols, session.rows);
}

await vm.pty.close(sessionId);

close() kills and removes the guest session. detach() leaves it running.

Lifecycle Behavior

  • A client disconnect does not end the guest session.
  • Pausing freezes the entire VM, including its PTY processes. Start the VM before attaching again.
  • Stopping or deleting the VM terminates its sessions.
  • Client input — keystrokes, resizes, signals — counts as VM activity: it resets an idle timeout, and typing into a paused VM’s session wakes the VM. Output alone does not, so a VM that only prints can still idle out.

Choose A Linux User (Optional)

To run a session as a specific existing Linux user, use vm.linuxUser("developer").pty instead of vm.pty. Selecting a user does not create that account inside the guest.

An identity grant without allowedLinuxUsers permits any Linux user on that VM, including the default. To restrict an identity to specific users, set allowedLinuxUsers when granting access:

// Server: restrict this identity's access to the developer user.
await identity.permissions.vm.grant({ vmId, allowedLinuxUsers: ["developer"] });

// Browser: explicitly select a user permitted by that grant.
const pty = freestyle.vms.ref(vmId).linuxUser("developer").pty;

With a restricted grant, select an allowed user when opening, attaching, listing, or closing sessions. Requests that omit the user are rejected. The SDK sends this selection as linuxUser in the API query.

esc