Browser terminals
This guide shows you how to connect a browser-based terminal to a sandbox shell. You can use the SandboxAddon with xterm.js, or connect directly over WebSockets.
You need an existing Cloudflare Worker with a sandbox binding. Refer to Getting started if you do not have one.
Install the terminal dependencies in your frontend project:
npm install @xterm/xterm @xterm/addon-fit @cloudflare/sandboxyarn install @xterm/xterm @xterm/addon-fit @cloudflare/sandboxpnpm install @xterm/xterm @xterm/addon-fit @cloudflare/sandboxIf you are not using xterm.js, you only need @cloudflare/sandbox for types.
Add a route that proxies WebSocket connections to the sandbox terminal. The example below supports both the default session and named sessions via a query parameter:
import { getSandbox } from "@cloudflare/sandbox";
export { Sandbox } from "@cloudflare/sandbox";
export default { async fetch(request, env) { const url = new URL(request.url);
if ( url.pathname === "/ws/terminal" && request.headers.get("Upgrade") === "websocket" ) { const sandbox = getSandbox(env.Sandbox, "my-sandbox"); const sessionId = url.searchParams.get("session");
if (sessionId) { const session = await sandbox.getSession(sessionId); return await session.terminal(request); }
return await sandbox.terminal(request, { cols: 80, rows: 24 }); }
return new Response("Not found", { status: 404 }); },};import { getSandbox } from '@cloudflare/sandbox';
export { Sandbox } from '@cloudflare/sandbox';
export default { async fetch(request: Request, env: Env): Promise<Response> { const url = new URL(request.url);
if (url.pathname === '/ws/terminal' && request.headers.get('Upgrade') === 'websocket') { const sandbox = getSandbox(env.Sandbox, 'my-sandbox'); const sessionId = url.searchParams.get('session');
if (sessionId) { const session = await sandbox.getSession(sessionId); return await session.terminal(request); }
return await sandbox.terminal(request, { cols: 80, rows: 24 }); }
return new Response('Not found', { status: 404 }); }};Create the terminal in your browser code and attach the SandboxAddon. The addon manages the WebSocket connection, automatic reconnection, and resize forwarding.
import { Terminal } from "@xterm/xterm";import { FitAddon } from "@xterm/addon-fit";import { SandboxAddon } from "@cloudflare/sandbox/xterm";import "@xterm/xterm/css/xterm.css";
const terminal = new Terminal({ cursorBlink: true });const fitAddon = new FitAddon();terminal.loadAddon(fitAddon);
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, error) => { console.log(`Terminal ${state}`, error ?? ""); },});
terminal.loadAddon(addon);terminal.open(document.getElementById("terminal"));fitAddon.fit();
// Connect to the default sessionaddon.connect({ sandboxId: "my-sandbox" });
// Or connect to a specific session// addon.connect({ sandboxId: 'my-sandbox', sessionId: 'development' });
window.addEventListener("resize", () => fitAddon.fit());import { Terminal } from '@xterm/xterm';import { FitAddon } from '@xterm/addon-fit';import { SandboxAddon } from '@cloudflare/sandbox/xterm';import '@xterm/xterm/css/xterm.css';
const terminal = new Terminal({ cursorBlink: true });const fitAddon = new FitAddon();terminal.loadAddon(fitAddon);
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, error) => { console.log(`Terminal ${state}`, error ?? ''); }});
terminal.loadAddon(addon);terminal.open(document.getElementById('terminal'));fitAddon.fit();
// Connect to the default sessionaddon.connect({ sandboxId: 'my-sandbox' });
// Or connect to a specific session// addon.connect({ sandboxId: 'my-sandbox', sessionId: 'development' });
window.addEventListener('resize', () => fitAddon.fit());For the full addon API, refer to the Terminal API reference.
If you are building a custom terminal UI or running in an environment without xterm.js, connect directly over WebSockets. The protocol uses binary frames for terminal data and JSON text frames for control messages.
const ws = new WebSocket("wss://example.com/ws/terminal?id=my-sandbox");ws.binaryType = "arraybuffer";
const decoder = new TextDecoder();const encoder = new TextEncoder();
ws.addEventListener("message", (event) => { if (event.data instanceof ArrayBuffer) { // Terminal output (binary) — includes ANSI escape sequences const text = decoder.decode(event.data); appendToDisplay(text); return; }
// Control message (JSON text) const msg = JSON.parse(event.data);
switch (msg.type) { case "ready": // Terminal is accepting input — send initial resize ws.send(JSON.stringify({ type: "resize", cols: 80, rows: 24 })); break;
case "exit": console.log(`Shell exited: code ${msg.code}`); break;
case "error": console.error("Terminal error:", msg.message); break; }});
// Send keystrokes as binaryfunction sendInput(text) { if (ws.readyState === WebSocket.OPEN) { ws.send(encoder.encode(text)); }}const ws = new WebSocket('wss://example.com/ws/terminal?id=my-sandbox');ws.binaryType = 'arraybuffer';
const decoder = new TextDecoder();const encoder = new TextEncoder();
ws.addEventListener('message', (event) => { if (event.data instanceof ArrayBuffer) { // Terminal output (binary) — includes ANSI escape sequences const text = decoder.decode(event.data); appendToDisplay(text); return; }
// Control message (JSON text) const msg = JSON.parse(event.data);
switch (msg.type) { case 'ready': // Terminal is accepting input — send initial resize ws.send(JSON.stringify({ type: 'resize', cols: 80, rows: 24 })); break;
case 'exit': console.log(`Shell exited: code ${msg.code}`); break;
case 'error': console.error('Terminal error:', msg.message); break; }});
// Send keystrokes as binaryfunction sendInput(text: string): void { if (ws.readyState === WebSocket.OPEN) { ws.send(encoder.encode(text)); }}Key protocol details:
- Set
binaryTypetoarraybufferbefore connecting. - Buffered output from a previous connection arrives as binary frames before the
readymessage. - Send keystrokes as binary (UTF-8). Send control messages (
resize) as JSON text. - The PTY stays alive when a client disconnects. Reconnecting replays buffered output.
For the full protocol specification, refer to the WebSocket protocol section in the API reference.
- Always use FitAddon — Without it, terminal dimensions do not match the container and text wraps incorrectly.
- Handle resize events — Call
fitAddon.fit()on window resize so the terminal and PTY stay in sync. - Clean up on unmount — Call
addon.disconnect()when removing the terminal from the page. - Use sessions for isolation — If users need separate shell environments, create sessions with different working directories and environment variables.
- Terminal API reference — Method signatures, addon API, and WebSocket protocol
- Terminal connections — How terminal connections work
- Session management — How sessions work