Back to blog

Streaming a real terminal to a phone: e2b-mobile's WebSocket architecture

September 8, 2026 4 min read
mobiletypescriptwebsockets

What "a terminal on your phone" actually requires

It's tempting to think a mobile terminal is just a scrolling text view: receive some output, append it to a string, render it. That works for a log viewer. It does not work for a terminal, because real shell output isn't plain text — it's plain text interleaved with ANSI escape codes that move the cursor, redraw the current line, clear the screen, and set colors. vim, a progress bar, top, even a shell prompt that redraws itself on backspace — all of that depends on a client that actually interprets those codes, not one that just prints bytes in order.

e2b-mobile lets you open a real interactive shell inside a remote E2B sandbox from a React Native app. Getting that working end-to-end meant solving two separate problems: how do you run a real terminal emulator inside a mobile app, and what actually goes over the wire between the phone and the sandbox.

No native terminal emulator exists for React Native, so don't fight that

There's no RN port of a real terminal emulator with cursor addressing and escape-code handling. xterm.js is the real, battle-tested implementation, but it's a browser library. The fix is to stop pretending React Native is the right place to run it — TerminalView renders a WebView with xterm.js loaded from a CDN inside it, and treats that WebView as the actual terminal:

const term = new Terminal({
  convertEol: true,
  fontSize: 13,
  theme: { background: '#0b0f19', foreground: '#e5e7eb', cursor: '#22c55e' },
});
const fitAddon = new FitAddon.FitAddon();
term.loadAddon(fitAddon);
term.open(document.getElementById('terminal'));

The React Native side never touches terminal rendering logic at all. It just needs a way to get bytes in and keystrokes out of that WebView, which is what postMessage/injectJavaScript are for — term.onData inside the WebView posts a message back to React Native on every keystroke, and the native side calls injectJavaScript('window.writeToTerminal(...)') to push new output in. The terminal itself — cursor movement, redraws, resize, everything — is xterm.js's problem, running in its native environment, not something reimplemented for mobile.

The actual wire protocol

The WebSocket connects to /api/v1/sandboxes/:id/terminal, and the protocol is a small, deliberate split: binary frames are raw keystroke bytes going in either direction, text frames are JSON control messages. There's exactly one control message type right now — resize:

if (!isBinary) {
  const parsed = JSON.parse(data.toString('utf8'));
  if (isResizeMessage(parsed)) {
    await sandbox.pty.resize(ptyPid, { cols: parsed.cols, rows: parsed.rows });
  }
  return;
}
await sandbox.pty.sendInput(ptyPid, new Uint8Array(data));

On connect, the backend creates a real PTY inside the E2B sandbox and wires its output directly to the socket:

const handle = await sandbox.pty.create({
  cols: 80,
  rows: 24,
  onData: (data) => {
    if (ws.readyState === WebSocket.OPEN) ws.send(data);
  },
});

Everything the shell prints — including every escape code — goes straight from the PTY to the socket, unmodified. The backend doesn't parse or understand any of it; it's a pure relay. All the interpretation happens client-side in xterm.js, which is exactly the division of responsibility that makes this maintainable: the server doesn't need to know anything about terminal semantics, and the terminal emulator doesn't need to know anything about sandboxes or WebSockets.

One React Native-specific gotcha

React Native's Blob implementation doesn't have .text() or .arrayBuffer() the way a browser's does. The fix is forcing arraybuffer as the WebSocket's binary type explicitly, rather than relying on the default:

ws.binaryType = 'arraybuffer';

It's a one-line fix, but it's the kind of gap that only shows up once you're running against React Native's actual WebSocket shim instead of a browser's — the web spec assumption doesn't hold, and there's no error message pointing at it; output just silently never arrives on the binary path.

What this doesn't handle

This needs a real, long-lived connection between phone and backend — it's why the backend deploys as a persistent Docker process rather than something serverless, since a serverless platform can recycle the process holding the WebSocket open at any point. There's also no reconnect-with-scrollback: if the connection drops, reconnect() opens a fresh WebSocket to a fresh PTY, and whatever was on screen before the drop is gone. For an interactive coding session that's an acceptable tradeoff; it wouldn't be for something like a long-running build log you need to page back through after a network blip.