---
title: "How to Run a Web Terminal in a Sandbox"
description: "Stream a VM's PTY to the browser — bridge it to an xterm.js client through a small WebSocket proxy (read-only or interactive), or bake ttyd into a snapshot."
url: "/docs/guides/run-a-web-terminal-in-a-sandbox"
---

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](https://xtermjs.org/) client — in either read-only or interactive mode — and [ttyd](https://github.com/tsl0922/ttyd), a turnkey terminal server you bake into a snapshot.


## Install the SDK

```bash pnpm
pnpm add freestyle
```

```bash bun
bun add freestyle
```

```bash npm
npm install freestyle
```

```bash yarn
yarn add freestyle
```

Set your API key before calling the API:

```bash
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.

```bash
npm install ws
```

```ts {7,29}
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:

```ts
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.

```bash
npm install @xterm/xterm @xterm/addon-fit
```

```ts {17-19}
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](https://github.com/tsl0922/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.

```ts {16}
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](https://www.freestyle.sh/docs/vms/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:

```ts
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.
```
