---
description: Replace @cloudflare/sandbox 0.12 terminal(), session.terminal(), proxyTerminal(), and SandboxAddon with tmux sessions that your Durable Object runs.
title: Move browser terminals
image: https://developers.cloudflare.com/sandbox/sdk/migrate/terminals/og.png?v=ede3fefcaf00d10b
---

[Skip to content](#main-content)

> Documentation Index  
> Fetch the complete documentation index at: https://developers.cloudflare.com/sandbox/llms.txt  
> Use this file to discover all available pages before exploring further.

# Move browser terminals

Last updated Sep 30, 2026|Copy as Markdown| [View as Markdown](https://developers.cloudflare.com/sandbox/sdk/migrate/terminals/index.md)| [Agent setup](https://developers.cloudflare.com/agent-setup/)

In Sandbox SDK 0.12, `terminal()` attaches a WebSocket to a shell that the container keeps for each session, and `SandboxAddon` reconnects the browser to it. In 1.0, your Durable Object attaches each WebSocket to a [tmux ↗︎](https://github.com/tmux/tmux/wiki) session with `exec(argv, { pty })`. tmux keeps the shell running between connections.

## Before you start

Your 1.0 class replaces the 0.12 `Sandbox` class, and has the `container` getter, the `ensureRunning()` method, and the `ENV` constant from [Replace the Sandbox class](https://developers.cloudflare.com/sandbox/sdk/migrate/replace-the-sandbox-class/).

This page replaces `terminal()`, `session.terminal()`, `proxyTerminal()`, and `SandboxAddon` from `@cloudflare/sandbox/xterm`. It does not cover sessions for commands. For those, refer to [Replace sessions](https://developers.cloudflare.com/sandbox/sdk/migrate/commands/#replace-sessions).

## Attach terminals to tmux

1. Add tmux to your `Dockerfile`:

   *Dockerfiledockerfile*

   

   ```dockerfile
   RUN apt-get update \
   	&& apt-get install -y --no-install-recommends \
   		ca-certificates git python3 tmux \
   	&& rm -rf /var/lib/apt/lists/*

   # Scroll back with the mouse wheel, and keep more history.
   RUN printf 'set -g mouse on\nset -g history-limit 50000\n' \
   	> /etc/tmux.conf
   ```


2. Add the `fetch()` handler from [Open a terminal in the browser](https://developers.cloudflare.com/sandbox/commands/open-a-terminal-in-the-browser/#open-a-terminal) to your class. Replace its `container` constant, `start()` call, and `setInactivityTimeout()` call with `ensureRunning()`, and start the shell with the directory and variables that 0.12 used:

   *src/index.tsts*

   

   ```ts
   await this.ensureRunning();

   const abort = new AbortController();
   const tmux = await this.container.exec(
   	["tmux", "new-session", "-A", "-s", session],
   	{
   		pty: {
   			cols: Number(url.searchParams.get("cols")) || 80,
   			rows: Number(url.searchParams.get("rows")) || 24,
   		},
   		env: { ...ENV, TERM: "xterm-256color" },
   		cwd: "/workspace",
   		signal: abort.signal,
   	},
   );
   ```

   0.12 started each shell in `/workspace`, with the variables from `envVars` and from the `ENV` lines of the image. `exec()` inherits only `PATH` from either, so add any other image variables that your shells need to `ENV`.
3. Send the message that 0.12 clients wait for, right after `accept()`:

   *src/index.tsts*

   

   ```ts
   server.binaryType = "arraybuffer";
   server.accept();

   // 0.12 clients wait for this message before they send their size.
   server.send(JSON.stringify({ type: "ready" }));
   ```

   `SandboxAddon` and other clients written for the 0.12 messages stay in the `connecting` state until `{"type":"ready"}` arrives. Then they send the window size. The handler already reads `cols` and `rows` from the 0.12 resize message, which also carries `"type": "resize"`. With this message, a page loaded before the switch keeps working when it reconnects.
4. If `createSession()` gave a session its own working directory and variables, pass them to tmux when a page opens that session:

   *src/index.tsts*

   

   ```ts
   // The working directory and variables of each 0.12 session
   // that browsers open by name.
   type SessionOptions = { cwd: string; env: Record<string, string> };

   const SESSIONS: Record<string, SessionOptions> = {
   	build: { cwd: "/workspace/app", env: { CI: "1" } },
   };
   ```

   In the handler, build the tmux command from the session name, and pass `argv` to `exec()` in place of the array:

   *src/index.tsts*

   

   ```ts
   const argv = ["tmux", "new-session", "-A", "-s", session];
   const options = SESSIONS[session];

   if (options) {
   	argv.push("-c", options.cwd);

   	for (const [name, value] of Object.entries(options.env)) {
   		argv.push("-e", `${name}=${value}`);
   	}
   }
   ```

   tmux applies `-c` and `-e` when it creates the session. A request without `?session=` opens the session `main`, which replaces the 0.12 default session.

   The handler accepts session names of lowercase letters, digits, and hyphens, and tmux does not accept `.` or `:`. Rename 0.12 sessions that use other characters.

   To run another command instead of `bash`, as the 0.12 `shell` option did, refer to [Run another command in the terminal](https://developers.cloudflare.com/sandbox/commands/open-a-terminal-in-the-browser/#run-another-command-in-the-terminal).
5. If your class forwards preview URLs from [Move preview URLs](https://developers.cloudflare.com/sandbox/sdk/migrate/preview-urls/), it already has a `fetch()` handler. Rename that handler to `forwardToPort()`, rename the terminal handler to `openTerminal()`, make both `private`, and add a `fetch()` handler that chooses one:

   *src/index.tsts*

   

   ```ts
   export class MySandbox extends DurableObject<Env> {
   	// ...

   	async fetch(request: Request): Promise<Response> {
   		// Only the preview route of the Worker sets this header.
   		if (request.headers.has("X-Sandbox-Port")) {
   			return this.forwardToPort(request);
   		}

   		return this.openTerminal(request);
   	}
   }
   ```



## Forward the WebSocket

In 0.12, the Worker passes the WebSocket request to `terminal()`:

*src/index.ts (0.12)ts*

```ts
const sandbox = getSandbox(env.MY_SANDBOX, id);
const sessionId = url.searchParams.get("session");

if (sessionId) {
	const session = await sandbox.getSession(sessionId);
	return session.terminal(request);
}

return sandbox.terminal(request, { cols: 80, rows: 24 });
```

In 1.0, the Worker forwards the request to the Durable Object, and the `session` query parameter goes with it:

*src/index.ts (1.0)ts*

```ts
return env.MY_SANDBOX.getByName(id.toLowerCase()).fetch(request);
```

Replace `proxyTerminal(stub, sessionId, request)` with the same `fetch()` call, and put the session in the `session` query parameter. The handler reads the size from the `cols` and `rows` query parameters, and uses 80 by 24 without them. A request without a WebSocket upgrade gets HTTP `426`, where 0.12 threw an error.

If your class also forwards preview URLs, remove the `X-Sandbox-Port` header before you forward the request. Otherwise a visitor who sends the header reaches a port without a preview token:

*src/index.ts (1.0)ts*

```ts
const headers = new Headers(request.headers);
headers.delete("X-Sandbox-Port");

const sandbox = env.MY_SANDBOX.getByName(id.toLowerCase());
return sandbox.fetch(new Request(request, { headers }));
```

## Replace SandboxAddon

1.0 has no `@cloudflare/sandbox/xterm` module. In 0.12, the page loads `SandboxAddon` into xterm.js:

*terminal.ts (0.12)ts*

```ts
import { SandboxAddon } from "@cloudflare/sandbox/xterm";

const addon = new SandboxAddon({
	getWebSocketUrl: ({ sandboxId, sessionId, origin }) => {
		const params = new URLSearchParams({ id: sandboxId });
		if (sessionId) params.set("session", sessionId);
		return `${origin}/ws/terminal?${params}`;
	},
	onStateChange: (state) => console.log(state),
});

terminal.loadAddon(addon);
addon.connect({ sandboxId: "my-sandbox", sessionId: "build" });
```

In 1.0, the page opens the WebSocket itself, and reconnects when it closes:

*terminal.ts (1.0)ts*

```ts
const encoder = new TextEncoder();
let socket: WebSocket;
let attempts = 0;

const send = (data: string | Uint8Array) => {
	if (socket.readyState === WebSocket.OPEN) socket.send(data);
};

const connect = () => {
	const url = new URL("/ws/terminal", location.href);
	url.protocol = location.protocol === "https:" ? "wss:" : "ws:";
	url.searchParams.set("id", "my-sandbox");
	url.searchParams.set("session", "build");
	url.searchParams.set("cols", String(terminal.cols));
	url.searchParams.set("rows", String(terminal.rows));

	socket = new WebSocket(url);
	socket.binaryType = "arraybuffer";

	socket.addEventListener("open", () => {
		attempts = 0;
		// tmux redraws the whole screen when a client attaches.
		terminal.reset();
	});
	socket.addEventListener("message", (event) => {
		// Skip the ready message.
		if (typeof event.data === "string") return;
		terminal.write(new Uint8Array(event.data));
	});
	socket.addEventListener("close", (event) => {
		// Code 1000 means the shell exited or the person detached.
		if (event.code === 1000 || attempts >= 10) return;
		const delay = Math.min(1000 * 2 ** attempts++, 30_000);
		setTimeout(connect, delay);
	});
};

connect();
terminal.onData((data) => send(encoder.encode(data)));
terminal.onResize(({ cols, rows }) => {
	send(JSON.stringify({ cols, rows }));
});
```

The states of the addon map to socket events. `connecting` lasts until the `open` event, and `connected` starts with it. `disconnected` follows a `close` event that the page does not retry.

Like the addon, the page retries after 1 second, doubles the delay, and stops after 10 attempts. An HTTP error in place of the upgrade arrives as a `close` event with code `1006`.

## What changes at the terminal

A reconnected page shows the screen that tmux redraws, where 0.12 replayed up to 256 KiB of earlier output. Earlier output stays in the tmux history, so people scroll back with the mouse wheel and press `q` to leave the history.

When the shell exits, the socket closes with code `1000`, and the reason carries the exit code, such as `Terminal closed with code 0`. The next connection starts a new shell. 0.12 kept the ended terminal, so later connections showed its old output and ran nothing until the container restarted.

Detaching with `CTRL + B` then `D` also closes the socket with code `1000`, and the session keeps running.

Browsers that open the same session share one screen, as they did in 0.12.

## Terminals open at the switch

The switch ends the 0.12 shells and their output. Open terminals stop receiving data without a close event, so `SandboxAddon` does not reconnect. Ask people to reload the page after the deploy, or add the read timeout from [Plan the move](https://developers.cloudflare.com/sandbox/sdk/migrate/plan-the-move/). After a reload, the Durable Object creates a new tmux session.

## Check the terminal

After you deploy the switch, open a terminal for a sandbox and make the window 100 columns by 30 rows. In the browser developer tools, the first message on the WebSocket is `{"type":"ready"}`. Run this command in the terminal:

```sh
echo pwd=$(pwd) NODE_ENV=$NODE_ENV; stty size
```

The shell starts in `/workspace` with the variables from `ENV`, and tmux takes one row for its status line:

```txt
pwd=/workspace NODE_ENV=test
29 100
```

Start a loop that prints a line every second, and reload the page. The count has kept going. Run `exit`. The WebSocket closes with code `1000` and the reason `Terminal closed with code 0`, and the page does not reconnect.

## Related resources

- [Open a terminal in the browser](https://developers.cloudflare.com/sandbox/commands/open-a-terminal-in-the-browser/)
- [Replace the Sandbox class](https://developers.cloudflare.com/sandbox/sdk/migrate/replace-the-sandbox-class/)
- [Plan the move](https://developers.cloudflare.com/sandbox/sdk/migrate/plan-the-move/)
- [xterm.js documentation ↗︎](https://xtermjs.org/docs/)

Was this helpful?

YesNo

## On this page

[![](https://developers.cloudflare.com/_astro/logo.te5VL_aD.svg)Docs](https://developers.cloudflare.com/)

```json
{"@context":"https://schema.org","@type":"TechArticle","@id":"https://developers.cloudflare.com/sandbox/sdk/migrate/terminals/#page","headline":"Move browser terminals","description":"Replace @cloudflare/sandbox 0.12 terminal(), session.terminal(), proxyTerminal(), and SandboxAddon with tmux sessions that your Durable Object runs.","url":"https://developers.cloudflare.com/sandbox/sdk/migrate/terminals/","inLanguage":"en","image":"https://developers.cloudflare.com/sandbox/sdk/migrate/terminals/og.png?v=ede3fefcaf00d10b","dateModified":"2026-09-30","publisher":{"@type":"Organization","name":"Cloudflare","description":"One platform for your apps, agents, and workforce. Build, secure, and scale without managing infrastructure","url":"https://www.cloudflare.com/","sameAs":["https://github.com/cloudflare","https://www.linkedin.com/company/cloudflare","https://x.com/cloudflare"],"logo":{"@type":"ImageObject","url":"https://developers.cloudflare.com/logo.svg"},"address":{"@type":"PostalAddress","streetAddress":"101 Townsend St","addressLocality":"San Francisco","addressRegion":"CA","postalCode":"94107","addressCountry":"US"},"contactPoint":[{"@type":"ContactPoint","contactType":"Customer Support","url":"https://support.cloudflare.com/","availableLanguage":["English"]},{"@type":"ContactPoint","contactType":"Sales","url":"https://www.cloudflare.com/contact/","availableLanguage":["English"]}]},"isPartOf":{"@type":"WebSite","@id":"https://developers.cloudflare.com/#website","name":"Cloudflare Docs","url":"https://developers.cloudflare.com/"}}
```
