A Freestyle VM exposes a real pseudo-terminal over a WebSocket through vm.pty. This guide streams that terminal into a browser two ways: a small proxy that drives an xterm.js client — in either read-only or interactive mode — and ttyd, a turnkey terminal server you bake into a snapshot.
Install the SDK
pnpm add freestylebun add freestylenpm install freestyleyarn add freestyle Set your API key before calling the API:
export FREESTYLE_API_KEY="your-api-key"
Bridge the PTY to a Browser
A browser WebSocket can’t send the authorization headers vm.pty needs, so it can’t connect to the VM directly. Stand a small proxy in between: it holds your Freestyle credentials, opens the PTY server-side, and relays bytes to and from the browser. Flip one flag to make the terminal read-only or interactive.
npm install ws
import { freestyle } from "freestyle";
import { WebSocketServer } from "ws";
const { vm } = await freestyle.vms.create({ idleTimeoutSeconds: null });
// Or attach to an existing VM: const { vm } = await freestyle.vms.get({ vmId });
const WRITABLE = true; // set false for a read-only viewer
const wss = new WebSocketServer({ port: 8080 });
wss.on("connection", async (browser) => {
// One PTY per viewer. PTY output → browser as binary frames.
const session = await vm.pty.open({
cols: 80,
rows: 24,
onData: (bytes) => browser.readyState === browser.OPEN && browser.send(bytes),
onExit: () => browser.close(),
});
browser.on("message", (data, isBinary) => {
if (!isBinary) {
// Text frames are control messages (resize). Resizing is safe in read-only.
try {
const msg = JSON.parse(data.toString());
if (msg.type === "resize") session.resize({ cols: msg.cols, rows: msg.rows });
} catch {}
return;
}
// Binary frames are keystrokes — only forward them when interactive.
if (WRITABLE) session.write(new Uint8Array(data));
});
browser.on("close", () => session.detach());
});
Read-only is enforced on the server: when WRITABLE is false, keystroke frames are dropped and never reach the VM, so a viewer can watch but not type — you don’t rely on the client to behave. Resize stays allowed so the view reflows to each viewer’s window.
Choosing the shell. By default the PTY spawns the VM’s login shell (or /bin/sh). Pass exec to vm.pty.open() to launch a specific program instead — bash, a REPL, or your own CLI:
const session = await vm.pty.open({
exec: "/bin/bash", // omit for the login shell; or "python3", "node", etc.
cols: 80,
rows: 24,
onData: (bytes) => browser.readyState === browser.OPEN && browser.send(bytes),
onExit: () => browser.close(),
});
vm.pty.open() resolves only once the PTY’s WebSocket has connected and the server’s first frame arrives — it has no timeout of its own, so a suspended or unhealthy VM leaves it pending. Wrap it (e.g. Promise.race with a timer) and surface failures to the browser so a stuck open shows an error instead of a blank terminal.
The xterm.js Client
The browser side renders the stream with xterm.js. It sends keystrokes as binary frames and resizes as JSON, matching the proxy above.
npm install @xterm/xterm @xterm/addon-fit
import { Terminal } from "@xterm/xterm";
import { FitAddon } from "@xterm/addon-fit";
import "@xterm/xterm/css/xterm.css";
const term = new Terminal({ cursorBlink: true });
const fit = new FitAddon();
term.loadAddon(fit);
term.open(document.getElementById("terminal")!);
fit.fit();
const ws = new WebSocket("ws://localhost:8080");
ws.binaryType = "arraybuffer";
// PTY output → terminal.
ws.onmessage = (e) => term.write(new Uint8Array(e.data as ArrayBuffer));
// Keystrokes → PTY (binary). Drop this line for a read-only terminal,
// or construct the terminal with `new Terminal({ disableStdin: true })`.
term.onData((data) => ws.send(new TextEncoder().encode(data)));
// Keep the PTY sized to the viewport.
const sendResize = () => ws.send(JSON.stringify({ type: "resize", cols: term.cols, rows: term.rows }));
term.onResize(sendResize);
window.addEventListener("resize", () => fit.fit());
ws.onopen = () => {
fit.fit();
sendResize();
};
Open the page and the VM’s shell appears in the browser, live. With WRITABLE on (and term.onData wired), keystrokes reach the VM; with it off, the same page is a read-only viewer.
To share one terminal across many viewers — one typist, the rest watching — open a single session, then have other proxies join it with vm.pty.attach({ sessionId }) instead of open(). Every subscriber sees the same output; gate writes to just one.
Alternative: ttyd
If you don’t need to embed the terminal in your own UI, ttyd serves a full xterm.js terminal over its own HTTP/WebSocket server — bake it into a snapshot like any other service and route a domain to it.
ttyd is read-only by default; --writable allows input and --credential adds basic auth. Pick your own unique *.style.dev subdomain; it needs no DNS or verification and gets HTTPS automatically.
import { freestyle } from "freestyle";
const { vm: builder } = await freestyle.vms.create();
// Install the official static binary (the base image has no ttyd package).
await builder.exec(
"curl -fsSL https://github.com/tsl0922/ttyd/releases/download/1.7.7/ttyd.x86_64 " +
"-o /usr/local/bin/ttyd && chmod +x /usr/local/bin/ttyd",
);
// Run ttyd under systemd. Drop `--writable` for a read-only terminal;
// add `--credential user:secret` to require a login.
await builder.fs.writeTextFile(
"/etc/systemd/system/ttyd.service",
`[Service]
ExecStart=/usr/local/bin/ttyd --port 7681 --interface 0.0.0.0 --writable bash
Restart=always
[Install]
WantedBy=multi-user.target
`,
);
await builder.exec("systemctl daemon-reload && systemctl enable --now ttyd");
// Wait until ttyd serves, then snapshot the running server.
let code = "";
while (code !== "200") {
await new Promise((r) => setTimeout(r, 1000));
code = (
await builder.exec("curl -s -o /dev/null -w '%{http_code}' http://localhost:7681/")
).stdout.trim();
}
const { snapshotId } = await builder.snapshot();
await builder.delete();
// Boot from the snapshot and route a domain to ttyd's port.
const { vmId } = await freestyle.vms.create({ snapshotId, idleTimeoutSeconds: null });
const domain = `my-terminal-${crypto.randomUUID().slice(0, 8)}.style.dev`;
await freestyle.domains.mappings.create({ domain, vmId, vmPort: 7681 });
console.log(`https://${domain}`);
Open that URL and the VM’s terminal loads in the browser. Because the snapshot captured ttyd already running, every VM booted from it serves instantly. ttyd is reachable by anyone with the link — use --credential (and drop --writable) for anything you don’t want public.
Stream the Server’s Logs
vm.exec() buffers a command and only returns once it finishes, so it can’t show a long-running service’s output as it happens. To watch the logs live, open a PTY on the VM — a real terminal streamed over a WebSocket (server-side only, Node 22+) — and follow the unit’s journal. onData delivers the bytes as they arrive:
const session = await builder.pty.open({
cols: 120,
rows: 30,
onData: (bytes) => process.stdout.write(bytes), // live log lines
});
// Follow the service; new lines stream in until you detach.
session.write("journalctl -u ttyd -f\n");
// session.detach() drops your handle — the service keeps running in the VM.