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.